diff --git a/src/server.test.ts b/src/server.test.ts new file mode 100644 index 0000000..8be969e --- /dev/null +++ b/src/server.test.ts @@ -0,0 +1,27 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { startServer } from './server.js'; +import { buildDashboardData } from './aggregator.js'; + +// Regression guard for the "dashboard almost never opens on Windows" bug: +// the server binds 127.0.0.1 (IPv4 loopback only, by design — see the +// security comment in server.ts), but it used to open/report +// `http://localhost:`. On Windows `localhost` resolves to ::1 (IPv6) +// first, where nothing listens, so the browser hangs in SYN_SENT and the +// dashboard "almost never" comes up. The opened URL must therefore use the +// same loopback literal the server actually binds. +test('startServer reports a reachable URL using the 127.0.0.1 loopback literal, not "localhost"', async () => { + const data = buildDashboardData([]); + // Fixed uncommon port; startServer's findFreePort scans a +20 window from + // here, so a busy base port still resolves without flaking. (port 0 is + // unusable: findFreePort overloads 0 as its "no free port" sentinel.) + const handle = await startServer(data, { port: 38765, open: false }); + try { + assert.match(handle.url, /^http:\/\/127\.0\.0\.1:\d+\/?$/); + + const res = await fetch(handle.url); + assert.equal(res.status, 200); + } finally { + handle.stop(); + } +}); diff --git a/src/server.ts b/src/server.ts index 31ec35c..e95c43d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -15,6 +15,14 @@ const BRAND_LOGO_CONTENT_TYPES: Record = { '.webp': 'image/webp', }; +// Single source of truth for the loopback address. We bind here AND open the +// browser here. They must never diverge: binding 127.0.0.1 (IPv4 loopback +// only — see the security note at the serve() call) while opening +// `http://localhost` is the "dashboard almost never opens on Windows" bug — +// Windows resolves `localhost` to ::1 (IPv6) first, where nothing listens, so +// the browser hangs in SYN_SENT. +const DASHBOARD_HOST = '127.0.0.1'; + function isPortFree(port: number): Promise { return new Promise((resolve) => { const srv = createServer(); @@ -34,6 +42,13 @@ async function findFreePort(preferred: number): Promise { } export interface ServerHandle { + /** + * The loopback URL the server is actually bound to (with the resolved + * port, which may differ from the requested one). Callers that open or + * link to the dashboard MUST use this rather than reconstructing it from + * a host string — that drift is exactly the Windows IPv6 bug. + */ + url: string; /** * Force a fresh data reload and broadcast an SSE update to all * subscribed clients. Safe to call repeatedly; the underlying readData @@ -171,8 +186,8 @@ export async function startServer( // Bind to loopback only. The dashboard exposes /api/data unauthenticated // — project names, model IDs, session IDs — which has no business being // reachable from anything other than this machine. - const server = serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, (info) => { - const url = `http://localhost:${info.port}`; + const url = `http://${DASHBOARD_HOST}:${port}`; + const server = serve({ fetch: app.fetch, port, hostname: DASHBOARD_HOST }, () => { console.log(`\n Dashboard running at ${url}\n`); if (port !== options.port) { console.log(` (Port ${options.port} was in use, using ${port} instead)\n`); @@ -185,6 +200,7 @@ export async function startServer( }); return { + url, notifyDataChanged: async () => { await readData(true); broadcast();