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 9cf5bff..093b374 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -1,8 +1,20 @@ 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, 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 = `${BASE_URL}/s/${sessionId}`; + const url = `http://${toBrowserHost(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}`); });