Skip to content

Commit 9630bee

Browse files
feat: persist DevTunnel ID for stable URLs across restarts (#13)
* feat: persist DevTunnel ID for stable URLs across restarts Tunnel ID is saved to ~/.termbeam/tunnel.json and reused on next startup. Tunnel is no longer deleted on shutdown — only the host process is stopped. Add --new-tunnel flag to force a fresh tunnel. Closes #10 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: use --persisted-tunnel flag instead of changing --tunnel behavior - --tunnel remains ephemeral (no breaking change) — creates fresh, deletes on exit - --persisted-tunnel opts into persistence — saves ID, reuses across restarts, 30d expiry - Removes --new-tunnel flag (use --tunnel for a fresh one instead) - Ephemeral tunnels are deleted on shutdown, persisted ones are preserved Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: log tunnel mode and expiry in startup banner Shows 'ephemeral (expires in 1 day)' or 'persisted (expires in 30 days)' next to the public URL so users know what to expect. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: prevent hang on shutdown with tunnel cleanup Remove detached:true from tunnel host spawn so the child process dies with the parent. Remove process.on('exit') handler that called execSync (which can't run in exit handlers), causing the hang. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: prevent hang on ephemeral tunnel cleanup Use SIGKILL (Unix) / taskkill /T /F (Windows) to force-kill the host process tree before running devtunnel delete. Add 10s timeout to the delete command so it can't hang indefinitely. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: close server and WebSocket on shutdown to prevent hang Close HTTP server and WebSocket server before exiting so all handles are released. Add shuttingDown guard to prevent double-shutdown. Use unref'd timeout as force-exit safety net. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 14fa6d6 commit 9630bee

5 files changed

Lines changed: 151 additions & 21 deletions

File tree

docs/configuration.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
| --------------------- | ------------------------------- | --------- |
77
| `--password <pw>` | Set access password | None |
88
| `--generate-password` | Auto-generate a secure password ||
9-
| `--tunnel` | Create a public devtunnel URL | Off |
9+
| `--tunnel` | Create an ephemeral devtunnel URL | Off |
10+
| `--persisted-tunnel` | Create a reusable devtunnel URL (stable across restarts) | Off |
1011
| `--port <port>` | Server port | `3456` |
1112
| `--host <addr>` | Bind address | `0.0.0.0` |
1213
| `-h, --help` | Show help ||
@@ -67,12 +68,22 @@ termbeam --tunnel --generate-password
6768

6869
### DevTunnel
6970

70-
The `--tunnel` flag creates a public URL using [Azure DevTunnels](https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/):
71+
The `--tunnel` flag creates an ephemeral public URL using [Azure DevTunnels](https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/):
7172

7273
```bash
7374
termbeam --tunnel --password mysecret
7475
```
7576

77+
For a **stable URL** that persists across restarts, use `--persisted-tunnel`:
78+
79+
```bash
80+
termbeam --persisted-tunnel --password mysecret
81+
```
82+
83+
!!! info "Persisted vs Ephemeral Tunnels"
84+
- `--tunnel` — Creates a fresh tunnel each time, deleted on shutdown. Good for one-off use.
85+
- `--persisted-tunnel` — Saves the tunnel ID to `~/.termbeam/tunnel.json` and reuses it across restarts (30-day expiry). The URL stays the same so you can bookmark it on your phone. To get a fresh URL, just switch back to `--tunnel`.
86+
7687
!!! warning
7788
Always use a password when using `--tunnel`. The tunnel URL is publicly accessible.
7889

src/cli.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ Usage:
1212
Options:
1313
--password <pw> Set access password (or TERMBEAM_PASSWORD env var)
1414
--generate-password Auto-generate a secure password
15-
--tunnel Create a public devtunnel URL
15+
--tunnel Create a public devtunnel URL (ephemeral)
16+
--persisted-tunnel Create a reusable devtunnel URL (stable across restarts)
1617
--port <port> Set port (default: 3456, or PORT env var)
1718
--host <addr> Bind address (default: 0.0.0.0)
1819
-h, --help Show this help
@@ -90,6 +91,7 @@ function parseArgs() {
9091
const cwd = process.env.TERMBEAM_CWD || process.env.PTY_CWD || process.cwd();
9192
let password = process.env.TERMBEAM_PASSWORD || process.env.PTY_PASSWORD || null;
9293
let useTunnel = false;
94+
let persistedTunnel = false;
9395

9496
const args = process.argv.slice(2);
9597
const filteredArgs = [];
@@ -99,6 +101,9 @@ function parseArgs() {
99101
password = args[++i];
100102
} else if (args[i] === '--tunnel') {
101103
useTunnel = true;
104+
} else if (args[i] === '--persisted-tunnel') {
105+
useTunnel = true;
106+
persistedTunnel = true;
102107
} else if (args[i].startsWith('--password=')) {
103108
password = args[i].split('=')[1];
104109
} else if (args[i] === '--help' || args[i] === '-h') {
@@ -126,7 +131,7 @@ function parseArgs() {
126131
const { getVersion } = require('./version');
127132
const version = getVersion();
128133

129-
return { port, host, password, useTunnel, shell, shellArgs, cwd, defaultShell, version };
134+
return { port, host, password, useTunnel, persistedTunnel, shell, shellArgs, cwd, defaultShell, version };
130135
}
131136

132137
module.exports = { parseArgs, printHelp };

src/server.js

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,17 @@ setupRoutes(app, { auth, sessions, config });
3838
setupWebSocket(wss, { auth, sessions });
3939

4040
// --- Lifecycle ---
41+
let shuttingDown = false;
4142
function shutdown() {
43+
if (shuttingDown) return;
44+
shuttingDown = true;
4245
console.log('\n[termbeam] Shutting down...');
4346
sessions.shutdown();
4447
cleanupTunnel();
45-
process.exit(0);
48+
server.close();
49+
wss.close();
50+
// Force exit after giving connections time to close
51+
setTimeout(() => process.exit(0), 500).unref();
4652
}
4753

4854
process.on('SIGINT', shutdown);
@@ -52,7 +58,6 @@ process.on('uncaughtException', (err) => {
5258
cleanupTunnel();
5359
process.exit(1);
5460
});
55-
process.on('exit', cleanupTunnel);
5661

5762
// --- Start ---
5863
function getLocalIP() {
@@ -112,10 +117,12 @@ server.listen(config.port, config.host, async () => {
112117

113118
let publicUrl = null;
114119
if (config.useTunnel) {
115-
publicUrl = await startTunnel(config.port);
116-
if (publicUrl) {
120+
const tunnel = await startTunnel(config.port, { persisted: config.persistedTunnel });
121+
if (tunnel) {
122+
publicUrl = tunnel.url;
117123
console.log('');
118124
console.log(` 🌐 Public: ${publicUrl}`);
125+
console.log(` Tunnel: ${tunnel.mode} (expires in ${tunnel.expiry})`);
119126
} else {
120127
console.log('');
121128
console.log(' ⚠️ Tunnel failed to start. Using LAN only.');

src/tunnel.js

Lines changed: 105 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
const { execSync, spawn } = require('child_process');
22
const path = require('path');
33
const fs = require('fs');
4+
const os = require('os');
5+
6+
const TUNNEL_CONFIG_DIR = path.join(os.homedir(), '.termbeam');
7+
const TUNNEL_CONFIG_PATH = path.join(TUNNEL_CONFIG_DIR, 'tunnel.json');
48

59
let tunnelId = null;
610
let tunnelProc = null;
@@ -29,7 +33,45 @@ function findDevtunnel() {
2933
return null;
3034
}
3135

32-
async function startTunnel(port) {
36+
function loadPersistedTunnel() {
37+
try {
38+
if (fs.existsSync(TUNNEL_CONFIG_PATH)) {
39+
return JSON.parse(fs.readFileSync(TUNNEL_CONFIG_PATH, 'utf-8'));
40+
}
41+
} catch {}
42+
return null;
43+
}
44+
45+
function savePersistedTunnel(id) {
46+
fs.mkdirSync(TUNNEL_CONFIG_DIR, { recursive: true });
47+
fs.writeFileSync(TUNNEL_CONFIG_PATH, JSON.stringify({ tunnelId: id, createdAt: new Date().toISOString() }, null, 2));
48+
}
49+
50+
function deletePersisted() {
51+
const persisted = loadPersistedTunnel();
52+
if (persisted) {
53+
try {
54+
execSync(`"${devtunnelCmd}" delete ${persisted.tunnelId} -f`, { stdio: 'pipe' });
55+
console.log(`[termbeam] Deleted persisted tunnel ${persisted.tunnelId}`);
56+
} catch {}
57+
try {
58+
fs.unlinkSync(TUNNEL_CONFIG_PATH);
59+
} catch {}
60+
}
61+
}
62+
63+
function isTunnelValid(id) {
64+
try {
65+
execSync(`"${devtunnelCmd}" show ${id} --json`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
66+
return true;
67+
} catch {
68+
return false;
69+
}
70+
}
71+
72+
let isPersisted = false;
73+
74+
async function startTunnel(port, options = {}) {
3375
// Check if devtunnel CLI is installed
3476
const found = findDevtunnel();
3577
if (!found) {
@@ -66,16 +108,48 @@ async function startTunnel(port) {
66108
execSync(`"${devtunnelCmd}" user login`, { stdio: 'inherit' });
67109
}
68110

69-
const createOut = execSync(`"${devtunnelCmd}" create --expiration 1d --json`, { encoding: 'utf-8' });
70-
const tunnelData = JSON.parse(createOut);
71-
tunnelId = tunnelData.tunnel.tunnelId;
111+
const persisted = options.persisted;
112+
isPersisted = !!persisted;
72113

73-
execSync(`"${devtunnelCmd}" port create ${tunnelId} -p ${port} --protocol http`, { stdio: 'pipe' });
74-
execSync(`"${devtunnelCmd}" access create ${tunnelId} -p ${port} --anonymous`, { stdio: 'pipe' });
114+
// Try to reuse persisted tunnel
115+
let tunnelMode, tunnelExpiry;
116+
if (persisted) {
117+
tunnelMode = 'persisted';
118+
tunnelExpiry = '30 days';
119+
const saved = loadPersistedTunnel();
120+
if (saved && isTunnelValid(saved.tunnelId)) {
121+
tunnelId = saved.tunnelId;
122+
console.log(`[termbeam] Reusing persisted tunnel ${tunnelId}`);
123+
} else {
124+
if (saved) {
125+
console.log('[termbeam] Persisted tunnel expired, creating new one');
126+
}
127+
const createOut = execSync(`"${devtunnelCmd}" create --expiration 30d --json`, { encoding: 'utf-8' });
128+
const tunnelData = JSON.parse(createOut);
129+
tunnelId = tunnelData.tunnel.tunnelId;
130+
savePersistedTunnel(tunnelId);
131+
console.log(`[termbeam] Created new persisted tunnel ${tunnelId}`);
132+
}
133+
} else {
134+
tunnelMode = 'ephemeral';
135+
tunnelExpiry = '1 day';
136+
// Ephemeral tunnel — create fresh, will be deleted on shutdown
137+
const createOut = execSync(`"${devtunnelCmd}" create --expiration 1d --json`, { encoding: 'utf-8' });
138+
const tunnelData = JSON.parse(createOut);
139+
tunnelId = tunnelData.tunnel.tunnelId;
140+
console.log(`[termbeam] Created ephemeral tunnel ${tunnelId}`);
141+
}
142+
143+
// Idempotent port and access setup
144+
try {
145+
execSync(`"${devtunnelCmd}" port create ${tunnelId} -p ${port} --protocol http`, { stdio: 'pipe' });
146+
} catch {}
147+
try {
148+
execSync(`"${devtunnelCmd}" access create ${tunnelId} -p ${port} --anonymous`, { stdio: 'pipe' });
149+
} catch {}
75150

76151
const hostProc = spawn(devtunnelCmd, ['host', tunnelId], {
77152
stdio: ['pipe', 'pipe', 'pipe'],
78-
detached: true,
79153
});
80154
tunnelProc = hostProc;
81155

@@ -88,7 +162,7 @@ async function startTunnel(port) {
88162
const match = output.match(/(https:\/\/[^\s]+devtunnels\.ms[^\s]*)/);
89163
if (match) {
90164
clearTimeout(timeout);
91-
resolve(match[1]);
165+
resolve({ url: match[1], mode: tunnelMode, expiry: tunnelExpiry });
92166
}
93167
});
94168
hostProc.stderr.on('data', (data) => {
@@ -106,17 +180,35 @@ async function startTunnel(port) {
106180
}
107181

108182
function cleanupTunnel() {
109-
if (tunnelId) {
183+
const id = tunnelId;
184+
if (tunnelProc) {
110185
try {
111-
if (tunnelProc) tunnelProc.kill();
112-
execSync(`"${devtunnelCmd}" delete ${tunnelId} -f`, { stdio: 'pipe' });
113-
console.log('[termbeam] Tunnel cleaned up');
186+
// On Windows, kill the process tree to ensure all children die
187+
if (process.platform === 'win32' && tunnelProc.pid) {
188+
try {
189+
execSync(`taskkill /pid ${tunnelProc.pid} /T /F`, { stdio: 'pipe', timeout: 5000 });
190+
} catch { /* best effort */ }
191+
} else {
192+
tunnelProc.kill('SIGKILL');
193+
}
114194
} catch {
115195
/* best effort */
116196
}
117-
tunnelId = null;
118197
tunnelProc = null;
119198
}
199+
if (id) {
200+
tunnelId = null;
201+
if (isPersisted) {
202+
console.log('[termbeam] Tunnel host stopped (tunnel preserved for reuse)');
203+
} else {
204+
try {
205+
execSync(`"${devtunnelCmd}" delete ${id} -f`, { stdio: 'pipe', timeout: 10000 });
206+
console.log('[termbeam] Tunnel cleaned up');
207+
} catch {
208+
/* best effort — tunnel will expire on its own */
209+
}
210+
}
211+
}
120212
}
121213

122214
module.exports = { startTunnel, cleanupTunnel };

test/cli.test.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,21 @@ describe('CLI', () => {
9898
assert.strictEqual(config.port, 9999);
9999
});
100100

101+
it('should parse --persisted-tunnel flag', () => {
102+
process.argv = ['node', 'termbeam', '--persisted-tunnel'];
103+
const { parseArgs } = require('../src/cli');
104+
const config = parseArgs();
105+
assert.strictEqual(config.persistedTunnel, true);
106+
assert.strictEqual(config.useTunnel, true);
107+
});
108+
109+
it('should default persistedTunnel to false', () => {
110+
process.argv = ['node', 'termbeam'];
111+
const { parseArgs } = require('../src/cli');
112+
const config = parseArgs();
113+
assert.strictEqual(config.persistedTunnel, false);
114+
});
115+
101116
it('should combine multiple flags', () => {
102117
process.argv = [
103118
'node',

0 commit comments

Comments
 (0)