From 26676fdba43cc88ac39c91f1fd7066d457785b32 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Mon, 13 Jul 2026 01:02:31 +0000 Subject: [PATCH 1/2] fix(devframe): advertise a dialable origin when bound to a wildcard host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Binding the dev server to `0.0.0.0` (or `::`) makes it reachable on every interface, but that address isn't dialable from a browser — opening the printed `http://0.0.0.0:` URL yields a page whose same-origin RPC WebSocket (`ws://0.0.0.0:/__devframe_ws`) fails to connect. Map wildcard and loopback bind hosts to `localhost` when advertising the origin (ready banner, browser-open, dock/absolute URLs) and the baked WS endpoint, while still binding the socket to the wildcard host. This mirrors how Vite reports a `Local:` URL under `--host 0.0.0.0`. --- .../src/adapters/__tests__/dev.test.ts | 39 ++++++++++++++++++- packages/devframe/src/adapters/dev.ts | 5 ++- .../devframe/src/node/__tests__/utils.test.ts | 33 +++++++++++++++- packages/devframe/src/node/server.ts | 8 +++- packages/devframe/src/node/utils.ts | 32 +++++++++++---- 5 files changed, 104 insertions(+), 13 deletions(-) diff --git a/packages/devframe/src/adapters/__tests__/dev.test.ts b/packages/devframe/src/adapters/__tests__/dev.test.ts index 879a5ea9..f2b8f988 100644 --- a/packages/devframe/src/adapters/__tests__/dev.test.ts +++ b/packages/devframe/src/adapters/__tests__/dev.test.ts @@ -37,7 +37,9 @@ describe('adapters/dev', () => { try { expect(handle.port).toBe(port) - expect(handle.origin).toBe(`http://${host}:${port}`) + // The advertised origin is dialable: the loopback IP normalizes to + // `localhost` so a client can actually open it. + expect(handle.origin).toBe(`http://localhost:${port}`) const res = await fetch(`http://${host}:${port}/__connection.json`) expect(res.ok).toBe(true) @@ -51,6 +53,41 @@ describe('adapters/dev', () => { } }) + it('advertises a dialable origin when bound to the wildcard host', async () => { + const distDir = makeTmpDist() + const devframe = defineDevframe({ + id: 'devframe-test-wildcard', + name: 'Wildcard Host', + version: '0.0.0', + packageName: 'devframe-test', + homepage: 'https://example.test', + description: 'Test devframe.', + setup: () => {}, + }) + + // Binding to `0.0.0.0` listens on every interface, but that address isn't + // dialable from a browser — the advertised origin must fall back to a + // loopback host so the page (and its same-origin WS) actually connect. + const host = '0.0.0.0' + const port = await getPort({ port: 19795, host }) + const handle = await createDevServer(devframe, { + host, + port, + distDir, + openBrowser: false, + }) + + try { + expect(handle.origin).toBe(`http://localhost:${port}`) + // The socket still listens on the wildcard host, reachable via loopback. + const res = await fetch(`http://localhost:${port}/__connection.json`) + expect(res.ok).toBe(true) + } + finally { + await handle.close() + } + }) + it('createDevServer binds the WS endpoint to the advertised route', async () => { const distDir = makeTmpDist() const devframe = defineDevframe({ diff --git a/packages/devframe/src/adapters/dev.ts b/packages/devframe/src/adapters/dev.ts index 752d8e5f..31fb927c 100644 --- a/packages/devframe/src/adapters/dev.ts +++ b/packages/devframe/src/adapters/dev.ts @@ -13,6 +13,7 @@ import { createHostContext } from '../node/context' import { diagnostics } from '../node/diagnostics' import { createH3DevframeHost } from '../node/host-h3' import { startHttpAndWs } from '../node/server' +import { normalizeHttpServerUrl } from '../node/utils' import { normalizeBasePath, resolveBasePath } from './_shared' const DEFAULT_PORT = 9999 @@ -139,7 +140,9 @@ export async function createDevServer( const flags = options.flags ?? {} const basePath = options.basePath ? normalizeBasePath(options.basePath) : resolveBasePath(def, 'standalone') const app = options.app ?? new H3() - const origin = `http://${host}:${port}` + // A wildcard bind host (`0.0.0.0` / `::`) isn't dialable from a browser, so + // advertise a loopback origin for anything that hands a client an absolute URL. + const origin = normalizeHttpServerUrl(host, port) const h3Host = createH3DevframeHost({ origin, diff --git a/packages/devframe/src/node/__tests__/utils.test.ts b/packages/devframe/src/node/__tests__/utils.test.ts index bbce76ff..71c67f2f 100644 --- a/packages/devframe/src/node/__tests__/utils.test.ts +++ b/packages/devframe/src/node/__tests__/utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { normalizeHttpServerUrl } from '../utils' +import { formatHostForUrl, normalizeHttpServerUrl, toDialableHost } from '../utils' describe('normalizeHttpServerUrl', () => { it('formats ipv4 localhost as localhost', () => { @@ -13,4 +13,35 @@ describe('normalizeHttpServerUrl', () => { it('preserves non-ip hosts', () => { expect(normalizeHttpServerUrl('localhost', 9999)).toBe('http://localhost:9999') }) + + it('maps the ipv4 wildcard bind host to a dialable loopback host', () => { + expect(normalizeHttpServerUrl('0.0.0.0', 9710)).toBe('http://localhost:9710') + }) + + it('maps the ipv6 wildcard bind host to a dialable loopback host', () => { + expect(normalizeHttpServerUrl('::', 9710)).toBe('http://localhost:9710') + }) +}) + +describe('toDialableHost', () => { + it('rewrites wildcard and loopback bind hosts to localhost', () => { + expect(toDialableHost('0.0.0.0')).toBe('localhost') + expect(toDialableHost('::')).toBe('localhost') + expect(toDialableHost('127.0.0.1')).toBe('localhost') + expect(toDialableHost('')).toBe('localhost') + }) + + it('preserves routable hosts', () => { + expect(toDialableHost('example.com')).toBe('example.com') + expect(toDialableHost('192.168.1.10')).toBe('192.168.1.10') + expect(toDialableHost('::1')).toBe('::1') + }) +}) + +describe('formatHostForUrl', () => { + it('brackets ipv6 but leaves ipv4 / hostnames bare', () => { + expect(formatHostForUrl('::1')).toBe('[::1]') + expect(formatHostForUrl('example.com')).toBe('example.com') + expect(formatHostForUrl('0.0.0.0')).toBe('localhost') + }) }) diff --git a/packages/devframe/src/node/server.ts b/packages/devframe/src/node/server.ts index 608fc258..c5b00998 100644 --- a/packages/devframe/src/node/server.ts +++ b/packages/devframe/src/node/server.ts @@ -12,6 +12,7 @@ import { attachWsRpcTransport } from 'devframe/rpc/transports/ws-server' import { H3, toNodeHandler } from 'h3' import { diagnostics } from './diagnostics' import { getInternalContext } from './hub-internals/context' +import { formatHostForUrl, normalizeHttpServerUrl } from './utils' export interface StartHttpAndWsOptions { context: DevframeNodeContext @@ -260,13 +261,16 @@ export async function startHttpAndWs(options: StartHttpAndWsOptions): Promise { return Object.prototype.toString.call(value) === '[object Object]' } -export function normalizeHttpServerUrl(host: string, port: number | string): string { - const normalizedHost - = host === '127.0.0.1' - ? 'localhost' - : isIP(host) === 6 - ? `[${host}]` - : host +// Wildcard bind addresses (`0.0.0.0` / `::`) mean "listen on every interface"; +// they are not themselves dialable from a browser. When advertising a URL for a +// client to open (banner, browser-open, dock entries), fall back to loopback — +// the same thing Vite and friends do when bound to `--host 0.0.0.0`. +const NON_DIALABLE_HOSTS = new Set([ + '0.0.0.0', + '127.0.0.1', + '::', + '0000:0000:0000:0000:0000:0000:0000:0000', + '', // an empty host binds to all interfaces too +]) + +/** Map a bind host to a host a client can actually connect to. */ +export function toDialableHost(host: string): string { + return NON_DIALABLE_HOSTS.has(host) ? 'localhost' : host +} - return `http://${normalizedHost}:${port}` +/** Format a bind host for use in a URL authority (dialable, IPv6-bracketed). */ +export function formatHostForUrl(host: string): string { + const dialable = toDialableHost(host) + return isIP(dialable) === 6 ? `[${dialable}]` : dialable +} + +export function normalizeHttpServerUrl(host: string, port: number | string): string { + return `http://${formatHostForUrl(host)}:${port}` } From 7870fb442b23f65d4805c86ddd0d66f9af1ad251 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 14 Jul 2026 08:01:34 +0000 Subject: [PATCH 2/2] fix(hub): expect dialable localhost ws endpoint for loopback binds The dialable-origin normalization in devframe/node/utils now also maps 127.0.0.1 to localhost when formatting the WS endpoint URL advertised by startHttpAndWs. Update the hub context test to match, and refresh the devframe/node tsnapi export snapshots for the new formatHostForUrl/toDialableHost exports. --- packages/hub/src/node/__tests__/context.test.ts | 4 +++- tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts | 2 ++ tests/__snapshots__/tsnapi/devframe/node.snapshot.js | 9 ++++----- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/hub/src/node/__tests__/context.test.ts b/packages/hub/src/node/__tests__/context.test.ts index 6a34b202..51203123 100644 --- a/packages/hub/src/node/__tests__/context.test.ts +++ b/packages/hub/src/node/__tests__/context.test.ts @@ -58,8 +58,10 @@ describe('startHttpAndWs remote endpoint metadata', () => { port: 0, }) + // The advertised WS endpoint is dialable: the loopback IP normalizes to + // `localhost`, matching the HTTP origin's normalization. expect(getInternalContext(context).wsEndpoint).toEqual({ - url: `ws://127.0.0.1:${started.port}`, + url: `ws://localhost:${started.port}`, }) await started.close() diff --git a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts index 835c6efe..7eac313d 100644 --- a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts @@ -86,8 +86,10 @@ export declare function createRpcSharedStateServerHost(_: RpcFunctionsHost$1): R export declare function createRpcStreamingServerHost(_: RpcFunctionsHost$1): RpcStreamingHost; export declare function createScopedNodeContext(_: DevframeNodeContext, _: NS): DevframeScopedNodeContext; export declare function createStorage(_: CreateStorageOptions): SharedState; +export declare function formatHostForUrl(_: string): string; export declare function isObject(_: unknown): value is Record; export declare function normalizeHttpServerUrl(_: string, _: number | string): string; +export declare function toDialableHost(_: string): string; // #endregion // #region Other diff --git a/tests/__snapshots__/tsnapi/devframe/node.snapshot.js b/tests/__snapshots__/tsnapi/devframe/node.snapshot.js index 51b9606e..a6f40864 100644 --- a/tests/__snapshots__/tsnapi/devframe/node.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/node.snapshot.js @@ -1,11 +1,6 @@ /** * Generated by tsnapi — public API snapshot of `devframe/node` */ -// #region Functions -export function isObject(_) {} -export function normalizeHttpServerUrl(_, _) {} -// #endregion - // #region Other export { createH3DevframeHost } export { createHostContext } @@ -17,6 +12,10 @@ export { createStorage } export { DevframeAgentHost } export { DevframeDiagnosticsHost } export { DevframeViewHost } +export { formatHostForUrl } +export { isObject } +export { normalizeHttpServerUrl } export { RpcFunctionsHost } export { startHttpAndWs } +export { toDialableHost } // #endregion \ No newline at end of file