traforo 0.2.2 → 0.2.4
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/LICENSE +21 -0
- package/dist/lockfile.d.ts +36 -0
- package/dist/lockfile.js +73 -0
- package/dist/run-tunnel.js +90 -1
- package/package.json +13 -14
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Kimaki
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Port lockfile management for traforo tunnels.
|
|
3
|
+
*
|
|
4
|
+
* Stores one JSON file per active tunnel port in ~/.traforo/{port}.json.
|
|
5
|
+
* Used to detect port conflicts, show tunnel info in error messages,
|
|
6
|
+
* and let agents reuse existing tunnels instead of killing them.
|
|
7
|
+
*
|
|
8
|
+
* Override the lockfile directory with TRAFORO_HOME env var (useful for tests).
|
|
9
|
+
*/
|
|
10
|
+
export declare function getLockfileDir(): string;
|
|
11
|
+
export type LockfileData = {
|
|
12
|
+
tunnelId: string;
|
|
13
|
+
tunnelUrl: string;
|
|
14
|
+
port: number;
|
|
15
|
+
/** PID of the traforo tunnel process (used for liveness checks) */
|
|
16
|
+
tunnelPid: number;
|
|
17
|
+
/** PID of the child server process, if any */
|
|
18
|
+
serverPid?: number;
|
|
19
|
+
command: string[] | undefined;
|
|
20
|
+
cwd: string;
|
|
21
|
+
startedAt: string;
|
|
22
|
+
};
|
|
23
|
+
export declare function writeLockfile(port: number, data: LockfileData): void;
|
|
24
|
+
export declare function readLockfile(port: number): LockfileData | null;
|
|
25
|
+
/**
|
|
26
|
+
* Remove lockfile only if it belongs to this traforo instance.
|
|
27
|
+
* Prevents a late-exiting old process from deleting a newer instance's lockfile.
|
|
28
|
+
*/
|
|
29
|
+
export declare function removeLockfile(port: number, expectedTunnelPid?: number): void;
|
|
30
|
+
/**
|
|
31
|
+
* Check if the tunnel process in a lockfile is still alive.
|
|
32
|
+
* Uses tunnelPid (the traforo process) not serverPid, because
|
|
33
|
+
* the tunnel URL is only valid while the traforo process is running.
|
|
34
|
+
* Returns true (stale) if the tunnel process no longer exists.
|
|
35
|
+
*/
|
|
36
|
+
export declare function isLockfileStale(lock: LockfileData): boolean;
|
package/dist/lockfile.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Port lockfile management for traforo tunnels.
|
|
3
|
+
*
|
|
4
|
+
* Stores one JSON file per active tunnel port in ~/.traforo/{port}.json.
|
|
5
|
+
* Used to detect port conflicts, show tunnel info in error messages,
|
|
6
|
+
* and let agents reuse existing tunnels instead of killing them.
|
|
7
|
+
*
|
|
8
|
+
* Override the lockfile directory with TRAFORO_HOME env var (useful for tests).
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
export function getLockfileDir() {
|
|
14
|
+
return process.env.TRAFORO_HOME ?? path.join(os.homedir(), '.traforo');
|
|
15
|
+
}
|
|
16
|
+
function lockfilePath(port) {
|
|
17
|
+
return path.join(getLockfileDir(), `${port}.json`);
|
|
18
|
+
}
|
|
19
|
+
export function writeLockfile(port, data) {
|
|
20
|
+
try {
|
|
21
|
+
const dir = getLockfileDir();
|
|
22
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
23
|
+
fs.writeFileSync(lockfilePath(port), JSON.stringify(data, null, 2) + '\n');
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Non-critical — don't crash if we can't write the lockfile
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function readLockfile(port) {
|
|
30
|
+
try {
|
|
31
|
+
const raw = fs.readFileSync(lockfilePath(port), 'utf-8');
|
|
32
|
+
return JSON.parse(raw);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Remove lockfile only if it belongs to this traforo instance.
|
|
40
|
+
* Prevents a late-exiting old process from deleting a newer instance's lockfile.
|
|
41
|
+
*/
|
|
42
|
+
export function removeLockfile(port, expectedTunnelPid) {
|
|
43
|
+
try {
|
|
44
|
+
if (expectedTunnelPid != null) {
|
|
45
|
+
const lock = readLockfile(port);
|
|
46
|
+
if (lock && lock.tunnelPid !== expectedTunnelPid) {
|
|
47
|
+
return; // not ours — leave it alone
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
fs.unlinkSync(lockfilePath(port));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// Already gone or never existed
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Check if the tunnel process in a lockfile is still alive.
|
|
58
|
+
* Uses tunnelPid (the traforo process) not serverPid, because
|
|
59
|
+
* the tunnel URL is only valid while the traforo process is running.
|
|
60
|
+
* Returns true (stale) if the tunnel process no longer exists.
|
|
61
|
+
*/
|
|
62
|
+
export function isLockfileStale(lock) {
|
|
63
|
+
try {
|
|
64
|
+
// signal 0 doesn't kill — just checks if process exists
|
|
65
|
+
process.kill(lock.tunnelPid, 0);
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
// ESRCH = no such process (dead). EPERM = process exists but we
|
|
70
|
+
// can't signal it (e.g. PID 1) — treat as alive, not stale.
|
|
71
|
+
return error.code === 'ESRCH';
|
|
72
|
+
}
|
|
73
|
+
}
|
package/dist/run-tunnel.js
CHANGED
|
@@ -3,8 +3,26 @@ import { exec, spawn } from 'node:child_process';
|
|
|
3
3
|
import net from 'node:net';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
5
|
import { TunnelClient } from './client.js';
|
|
6
|
+
import { writeLockfile, readLockfile, removeLockfile, isLockfileStale, } from './lockfile.js';
|
|
6
7
|
const execPromise = promisify(exec);
|
|
7
8
|
export const CLI_NAME = 'traforo';
|
|
9
|
+
/**
|
|
10
|
+
* Shell-quote an argument array so the suggested command is copy-pasteable.
|
|
11
|
+
* Wraps args in single quotes if they contain shell-special characters.
|
|
12
|
+
*/
|
|
13
|
+
function shellQuote(args) {
|
|
14
|
+
return args
|
|
15
|
+
.map((arg) => {
|
|
16
|
+
if (arg === '')
|
|
17
|
+
return "''";
|
|
18
|
+
// Safe chars that don't need quoting
|
|
19
|
+
if (/^[a-zA-Z0-9._\-/:=@]+$/.test(arg))
|
|
20
|
+
return arg;
|
|
21
|
+
// Wrap in single quotes, escaping any inner single quotes
|
|
22
|
+
return "'" + arg.replace(/'/g, "'\\''") + "'";
|
|
23
|
+
})
|
|
24
|
+
.join(' ');
|
|
25
|
+
}
|
|
8
26
|
const DEFAULT_TUNNEL_ID_BYTES = 10;
|
|
9
27
|
export function createRandomTunnelId({ port }) {
|
|
10
28
|
return `${crypto.randomBytes(DEFAULT_TUNNEL_ID_BYTES).toString('hex')}-${port}`;
|
|
@@ -167,13 +185,71 @@ export async function runTunnel(options) {
|
|
|
167
185
|
// Kill existing process on port if requested
|
|
168
186
|
if (options.kill) {
|
|
169
187
|
await killProcessOnPort(port);
|
|
188
|
+
// Verify the port actually freed up before removing the lockfile
|
|
189
|
+
if (await isPortInUse(port, localHost)) {
|
|
190
|
+
console.error(`Error: Port ${port} is still in use after --kill.`);
|
|
191
|
+
console.error(`The process may require elevated permissions to terminate.`);
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
removeLockfile(port); // no ownership check — --kill is intentional
|
|
195
|
+
}
|
|
196
|
+
// Pre-flight: detect port conflict before spawning the child process
|
|
197
|
+
if (options.command && options.command.length > 0 && !options.kill) {
|
|
198
|
+
const portBusy = await isPortInUse(port, localHost);
|
|
199
|
+
if (portBusy) {
|
|
200
|
+
const lock = readLockfile(port);
|
|
201
|
+
if (lock && !isLockfileStale(lock)) {
|
|
202
|
+
const currentCwd = process.cwd();
|
|
203
|
+
const currentCmd = options.command;
|
|
204
|
+
const sameCwd = lock.cwd === currentCwd;
|
|
205
|
+
const sameCmd = lock.command &&
|
|
206
|
+
lock.command.length === currentCmd.length &&
|
|
207
|
+
lock.command.every((arg, i) => arg === currentCmd[i]);
|
|
208
|
+
if (sameCwd && sameCmd) {
|
|
209
|
+
// Same command in same directory — tell agent to reuse the tunnel
|
|
210
|
+
console.error(`Error: Port ${port} is already in use\n`);
|
|
211
|
+
console.error(` Tunnel: ${lock.tunnelUrl}`);
|
|
212
|
+
console.error(` ID: ${lock.tunnelId}`);
|
|
213
|
+
console.error(` Command: ${lock.command ? shellQuote(lock.command) : 'unknown'}`);
|
|
214
|
+
console.error(` Dir: ${lock.cwd}`);
|
|
215
|
+
console.error(` PID: ${lock.tunnelPid}`);
|
|
216
|
+
console.error(` Started: ${lock.startedAt}\n`);
|
|
217
|
+
console.error(`The same command in the same directory is already tunneled.`);
|
|
218
|
+
console.error(`Reuse the tunnel URL above instead of creating a new one.`);
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
// Different command or directory — suggest --kill or reuse
|
|
223
|
+
console.error(`Error: Port ${port} is already in use\n`);
|
|
224
|
+
console.error(` Tunnel: ${lock.tunnelUrl}`);
|
|
225
|
+
console.error(` ID: ${lock.tunnelId}`);
|
|
226
|
+
console.error(` Command: ${lock.command ? shellQuote(lock.command) : 'unknown'}`);
|
|
227
|
+
console.error(` Dir: ${lock.cwd}`);
|
|
228
|
+
console.error(` PID: ${lock.tunnelPid}`);
|
|
229
|
+
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)}`);
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
// Port busy but no lockfile or stale lockfile — unknown process
|
|
238
|
+
if (lock)
|
|
239
|
+
removeLockfile(port);
|
|
240
|
+
console.error(`Error: Port ${port} is already in use by another process.\n`);
|
|
241
|
+
console.error(`Use --kill to terminate it before starting:`);
|
|
242
|
+
console.error(` traforo -p ${port} --kill -- ${shellQuote(options.command)}`);
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
170
246
|
}
|
|
171
247
|
let child = null;
|
|
172
248
|
// If command provided, spawn child process with PORT env
|
|
173
249
|
if (options.command && options.command.length > 0) {
|
|
174
250
|
const cmd = options.command[0];
|
|
175
251
|
const args = options.command.slice(1);
|
|
176
|
-
console.log(`Starting: ${options.command
|
|
252
|
+
console.log(`Starting: ${shellQuote(options.command)}`);
|
|
177
253
|
console.log(`PORT=${port}\n`);
|
|
178
254
|
const spawnedChild = spawn(cmd, args, {
|
|
179
255
|
stdio: 'inherit',
|
|
@@ -193,6 +269,7 @@ export async function runTunnel(options) {
|
|
|
193
269
|
});
|
|
194
270
|
spawnedChild.on('exit', (code) => {
|
|
195
271
|
console.log(`\nCommand exited with code ${code}`);
|
|
272
|
+
removeLockfile(port, process.pid);
|
|
196
273
|
process.exit(code || 0);
|
|
197
274
|
});
|
|
198
275
|
// Wait for port to be available before connecting tunnel
|
|
@@ -225,6 +302,7 @@ export async function runTunnel(options) {
|
|
|
225
302
|
// Handle shutdown
|
|
226
303
|
const cleanup = () => {
|
|
227
304
|
console.log('\nShutting down...');
|
|
305
|
+
removeLockfile(port, process.pid);
|
|
228
306
|
client.close();
|
|
229
307
|
if (child) {
|
|
230
308
|
child.kill();
|
|
@@ -235,6 +313,17 @@ export async function runTunnel(options) {
|
|
|
235
313
|
process.on('SIGTERM', cleanup);
|
|
236
314
|
try {
|
|
237
315
|
await client.connect();
|
|
316
|
+
// Write lockfile so other traforo instances can detect this tunnel
|
|
317
|
+
writeLockfile(port, {
|
|
318
|
+
tunnelId,
|
|
319
|
+
tunnelUrl: client.url,
|
|
320
|
+
port,
|
|
321
|
+
tunnelPid: process.pid,
|
|
322
|
+
serverPid: child?.pid,
|
|
323
|
+
command: options.command,
|
|
324
|
+
cwd: process.cwd(),
|
|
325
|
+
startedAt: new Date().toISOString(),
|
|
326
|
+
});
|
|
238
327
|
}
|
|
239
328
|
catch (err) {
|
|
240
329
|
console.error('Failed to connect:', err instanceof Error ? err.message : String(err));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "traforo",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "HTTP tunnel via Cloudflare Durable Objects and WebSockets. Edge caching and password protection.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,18 +28,6 @@
|
|
|
28
28
|
"import": "./dist/run-tunnel.js"
|
|
29
29
|
}
|
|
30
30
|
},
|
|
31
|
-
"scripts": {
|
|
32
|
-
"build": "tsc -p tsconfig.client.json && chmod +x dist/cli.js",
|
|
33
|
-
"prepublishOnly": "pnpm build",
|
|
34
|
-
"dev": "wrangler dev",
|
|
35
|
-
"deploy": "wrangler deploy",
|
|
36
|
-
"deploy:preview": "wrangler deploy --env preview",
|
|
37
|
-
"typecheck": "tsc --noEmit",
|
|
38
|
-
"typecheck:client": "tsc --noEmit -p tsconfig.client.json",
|
|
39
|
-
"typecheck:test": "tsc --noEmit -p tsconfig.test.json",
|
|
40
|
-
"cli": "tsx src/cli.ts",
|
|
41
|
-
"test": "vitest --run"
|
|
42
|
-
},
|
|
43
31
|
"devDependencies": {
|
|
44
32
|
"@cloudflare/workers-types": "^4.20250712.0",
|
|
45
33
|
"@types/http-cache-semantics": "^4.2.0",
|
|
@@ -57,5 +45,16 @@
|
|
|
57
45
|
"http-cache-semantics": "^4.2.0",
|
|
58
46
|
"string-dedent": "^3.0.2",
|
|
59
47
|
"ws": "^8.19.0"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsc -p tsconfig.client.json && chmod +x dist/cli.js",
|
|
51
|
+
"dev": "wrangler dev",
|
|
52
|
+
"deploy": "wrangler deploy",
|
|
53
|
+
"deploy:preview": "wrangler deploy --env preview",
|
|
54
|
+
"typecheck": "tsc --noEmit",
|
|
55
|
+
"typecheck:client": "tsc --noEmit -p tsconfig.client.json",
|
|
56
|
+
"typecheck:test": "tsc --noEmit -p tsconfig.test.json",
|
|
57
|
+
"cli": "tsx src/cli.ts",
|
|
58
|
+
"test": "vitest --run"
|
|
60
59
|
}
|
|
61
|
-
}
|
|
60
|
+
}
|