Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion src/cli/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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) => {
Expand Down
18 changes: 15 additions & 3 deletions src/cli/commands.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -33,7 +45,7 @@ export async function cmdGo(id = 'main'): Promise<void> {
sessionId = session.id;
}

const url = `${BASE_URL}/s/${sessionId}`;
const url = `http://${toBrowserHost(loadConfig().host)}:${PORT}/s/${sessionId}`;
console.log(url);
openBrowser(url);
}
Expand Down
2 changes: 1 addition & 1 deletion src/cli/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`. */
Expand Down
7 changes: 5 additions & 2 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, '..', '..');
Expand Down Expand Up @@ -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}`);
});
Loading