From da7991d400e5aacfcde88731f8c919dd018642c6 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Thu, 9 Apr 2026 12:52:28 -0400 Subject: [PATCH 1/3] fix: bind to 127.0.0.1 when config.host is 'localhost' to avoid IPv6 On modern macOS/Node, 'localhost' resolves to ::1 (IPv6), causing the server to fail or be unreachable when browsers block 127.0.0.1 URLs. Internal CLI API calls always use 127.0.0.1; the browser URL uses the configured host as-is so 'localhost' is preserved in opened URLs. Co-Authored-By: Claude Sonnet 4.6 --- src/cli/commands.ts | 6 +++--- src/cli/http.ts | 2 +- src/server/index.ts | 7 +++++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 9cf5bff..97b5733 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -1,8 +1,8 @@ import * as childProcess from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -import { configDir } from '../config'; -import { BASE_URL, isServerRunning, openBrowser, startServer, stopServer } from './http'; +import { configDir, loadConfig } from '../config'; +import { BASE_URL, PORT, isServerRunning, openBrowser, startServer, stopServer } from './http'; /** * Opens (or creates) session `id`, starts the server if needed, and opens the URL in the browser. @@ -33,7 +33,7 @@ export async function cmdGo(id = 'main'): Promise { sessionId = session.id; } - const url = `${BASE_URL}/s/${sessionId}`; + const url = `http://${loadConfig().host}:${PORT}/s/${sessionId}`; console.log(url); openBrowser(url); } diff --git a/src/cli/http.ts b/src/cli/http.ts index 01995a7..06ae6af 100644 --- a/src/cli/http.ts +++ b/src/cli/http.ts @@ -10,7 +10,7 @@ const __dirname = path.dirname(__filename); /** Active server port, resolved from `PORT` env or config default. */ export const PORT = Number(process.env.PORT) || 2346; -/** Base URL for the local server (always 127.0.0.1). */ +/** Base URL for internal CLI↔server API calls (always 127.0.0.1, avoids IPv6 lookup). */ export const BASE_URL = `http://127.0.0.1:${PORT}`; /** Returns the path to the server log file: `~/.config/webtty/server.log`. */ diff --git a/src/server/index.ts b/src/server/index.ts index 6d72350..d3ab79a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -12,7 +12,10 @@ const __dirname = path.dirname(__filename); const config = loadConfig(); const HTTP_PORT = Number(process.env.PORT) || config.port; -const HTTP_HOST = config.host; +// 'localhost' resolves to ::1 (IPv6) on modern macOS/Node; bind to 127.0.0.1 instead +// but keep 'localhost' as the display host so browser URLs use it as intended. +const HTTP_HOST_DISPLAY = config.host; +const HTTP_HOST = config.host === 'localhost' ? '127.0.0.1' : config.host; const { distPath, wasmPath } = findGhosttyWeb(); const projectRoot = path.resolve(__dirname, '..', '..'); @@ -46,5 +49,5 @@ process.on('SIGINT', () => { }); httpServer.listen(HTTP_PORT, HTTP_HOST, () => { - console.log(`listening on http://${HTTP_HOST}:${HTTP_PORT}`); + console.log(`listening on http://${HTTP_HOST_DISPLAY}:${HTTP_PORT}`); }); From 62b20417cad3bc769a2d220b7f8c0af952764722 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Thu, 9 Apr 2026 12:58:58 -0400 Subject: [PATCH 2/3] fix: sort PORT import alphabetically to satisfy biome import ordering Co-Authored-By: Claude Sonnet 4.6 --- src/cli/commands.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 97b5733..a7dc5e4 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -2,7 +2,7 @@ import * as childProcess from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { configDir, loadConfig } from '../config'; -import { BASE_URL, PORT, isServerRunning, openBrowser, startServer, stopServer } from './http'; +import { BASE_URL, isServerRunning, openBrowser, PORT, startServer, stopServer } from './http'; /** * Opens (or creates) session `id`, starts the server if needed, and opens the URL in the browser. From d79100ec44ba888c617d4b3d51418391c393de8c Mon Sep 17 00:00:00 2001 From: jesse23 Date: Thu, 9 Apr 2026 13:04:08 -0400 Subject: [PATCH 3/3] fix: handle 0.0.0.0/:: and bare IPv6 hosts in browser URL Extract toBrowserHost() to map bind-all addresses to 'localhost' and bracket bare IPv6 addresses, so the browser URL is always valid and routable regardless of config.host value. Add unit tests for all cases. Co-Authored-By: Claude Sonnet 4.6 --- src/cli/commands.test.ts | 40 +++++++++++++++++++++++++++++++++++++++- src/cli/commands.ts | 14 +++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/cli/commands.test.ts b/src/cli/commands.test.ts index ecc4840..88610bf 100644 --- a/src/cli/commands.test.ts +++ b/src/cli/commands.test.ts @@ -11,7 +11,7 @@ import { waitForServerDown, waitForServerReady, } from '../utils.test'; -import { bytesToChars, bytesToDisplay } from './commands'; +import { bytesToChars, bytesToDisplay, toBrowserHost } from './commands'; import * as httpModule from './http'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -183,6 +183,32 @@ describe('cli — session management', () => { }); }); +describe('toBrowserHost', () => { + test('localhost passes through unchanged', () => { + expect(toBrowserHost('localhost')).toBe('localhost'); + }); + + test('127.0.0.1 passes through unchanged', () => { + expect(toBrowserHost('127.0.0.1')).toBe('127.0.0.1'); + }); + + test('0.0.0.0 maps to localhost', () => { + expect(toBrowserHost('0.0.0.0')).toBe('localhost'); + }); + + test(':: maps to localhost', () => { + expect(toBrowserHost('::')).toBe('localhost'); + }); + + test('bare IPv6 address gets bracketed', () => { + expect(toBrowserHost('::1')).toBe('[::1]'); + }); + + test('already-bracketed IPv6 passes through unchanged', () => { + expect(toBrowserHost('[::1]')).toBe('[::1]'); + }); +}); + describe('bytesToDisplay', () => { test('ESC CR → legacy shift+enter', () => { expect(bytesToDisplay(Buffer.from([0x1b, 0x0d]))).toBe('ESC CR'); @@ -547,6 +573,18 @@ describe('cli — unit (mocked http)', () => { log.mockRestore(); }); + test('cmdGo URL uses toBrowserHost applied to config host', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + global.fetch = mock(async () => new Response(null, { status: 200 })) as unknown as typeof fetch; + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdGo('main'); + const printed: string = (log.mock.calls[0] as string[])[0]; + // URL must be a valid http URL ending in /s/main — host comes from config via toBrowserHost + expect(printed).toMatch(/^http:\/\/.+:\d+\/s\/main$/); + isRunning.mockRestore(); + log.mockRestore(); + }); + test('cmdGo session creation failure exits with error', async () => { const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); global.fetch = mock(async (url: string) => { diff --git a/src/cli/commands.ts b/src/cli/commands.ts index a7dc5e4..093b374 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -4,6 +4,18 @@ import path from 'node:path'; import { configDir, loadConfig } from '../config'; import { BASE_URL, isServerRunning, openBrowser, PORT, startServer, stopServer } from './http'; +/** + * Converts a bind host to a browser-navigable host. + * - Bind-all addresses (`0.0.0.0`, `::`) become `localhost`. + * - Bare IPv6 addresses are bracketed (`::1` → `[::1]`). + * - All other values pass through unchanged. + */ +export function toBrowserHost(host: string): string { + if (host === '0.0.0.0' || host === '::') return 'localhost'; + if (host.includes(':') && !host.startsWith('[')) return `[${host}]`; + return host; +} + /** * Opens (or creates) session `id`, starts the server if needed, and opens the URL in the browser. * @@ -33,7 +45,7 @@ export async function cmdGo(id = 'main'): Promise { sessionId = session.id; } - const url = `http://${loadConfig().host}:${PORT}/s/${sessionId}`; + const url = `http://${toBrowserHost(loadConfig().host)}:${PORT}/s/${sessionId}`; console.log(url); openBrowser(url); }