Skip to content

Commit bdd3aa9

Browse files
authored
fix(devframe): advertise a dialable origin when bound to a wildcard host (#89)
1 parent 976ce16 commit bdd3aa9

8 files changed

Lines changed: 113 additions & 19 deletions

File tree

packages/devframe/src/adapters/__tests__/dev.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ describe('adapters/dev', () => {
3737

3838
try {
3939
expect(handle.port).toBe(port)
40-
expect(handle.origin).toBe(`http://${host}:${port}`)
40+
// The advertised origin is dialable: the loopback IP normalizes to
41+
// `localhost` so a client can actually open it.
42+
expect(handle.origin).toBe(`http://localhost:${port}`)
4143

4244
const res = await fetch(`http://${host}:${port}/__connection.json`)
4345
expect(res.ok).toBe(true)
@@ -51,6 +53,41 @@ describe('adapters/dev', () => {
5153
}
5254
})
5355

56+
it('advertises a dialable origin when bound to the wildcard host', async () => {
57+
const distDir = makeTmpDist()
58+
const devframe = defineDevframe({
59+
id: 'devframe-test-wildcard',
60+
name: 'Wildcard Host',
61+
version: '0.0.0',
62+
packageName: 'devframe-test',
63+
homepage: 'https://example.test',
64+
description: 'Test devframe.',
65+
setup: () => {},
66+
})
67+
68+
// Binding to `0.0.0.0` listens on every interface, but that address isn't
69+
// dialable from a browser — the advertised origin must fall back to a
70+
// loopback host so the page (and its same-origin WS) actually connect.
71+
const host = '0.0.0.0'
72+
const port = await getPort({ port: 19795, host })
73+
const handle = await createDevServer(devframe, {
74+
host,
75+
port,
76+
distDir,
77+
openBrowser: false,
78+
})
79+
80+
try {
81+
expect(handle.origin).toBe(`http://localhost:${port}`)
82+
// The socket still listens on the wildcard host, reachable via loopback.
83+
const res = await fetch(`http://localhost:${port}/__connection.json`)
84+
expect(res.ok).toBe(true)
85+
}
86+
finally {
87+
await handle.close()
88+
}
89+
})
90+
5491
it('createDevServer binds the WS endpoint to the advertised route', async () => {
5592
const distDir = makeTmpDist()
5693
const devframe = defineDevframe({

packages/devframe/src/adapters/dev.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { createHostContext } from '../node/context'
1313
import { diagnostics } from '../node/diagnostics'
1414
import { createH3DevframeHost } from '../node/host-h3'
1515
import { startHttpAndWs } from '../node/server'
16+
import { normalizeHttpServerUrl } from '../node/utils'
1617
import { normalizeBasePath, resolveBasePath } from './_shared'
1718

1819
const DEFAULT_PORT = 9999
@@ -139,7 +140,9 @@ export async function createDevServer(
139140
const flags = options.flags ?? {}
140141
const basePath = options.basePath ? normalizeBasePath(options.basePath) : resolveBasePath(def, 'standalone')
141142
const app = options.app ?? new H3()
142-
const origin = `http://${host}:${port}`
143+
// A wildcard bind host (`0.0.0.0` / `::`) isn't dialable from a browser, so
144+
// advertise a loopback origin for anything that hands a client an absolute URL.
145+
const origin = normalizeHttpServerUrl(host, port)
143146

144147
const h3Host = createH3DevframeHost({
145148
origin,

packages/devframe/src/node/__tests__/utils.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest'
2-
import { normalizeHttpServerUrl } from '../utils'
2+
import { formatHostForUrl, normalizeHttpServerUrl, toDialableHost } from '../utils'
33

44
describe('normalizeHttpServerUrl', () => {
55
it('formats ipv4 localhost as localhost', () => {
@@ -13,4 +13,35 @@ describe('normalizeHttpServerUrl', () => {
1313
it('preserves non-ip hosts', () => {
1414
expect(normalizeHttpServerUrl('localhost', 9999)).toBe('http://localhost:9999')
1515
})
16+
17+
it('maps the ipv4 wildcard bind host to a dialable loopback host', () => {
18+
expect(normalizeHttpServerUrl('0.0.0.0', 9710)).toBe('http://localhost:9710')
19+
})
20+
21+
it('maps the ipv6 wildcard bind host to a dialable loopback host', () => {
22+
expect(normalizeHttpServerUrl('::', 9710)).toBe('http://localhost:9710')
23+
})
24+
})
25+
26+
describe('toDialableHost', () => {
27+
it('rewrites wildcard and loopback bind hosts to localhost', () => {
28+
expect(toDialableHost('0.0.0.0')).toBe('localhost')
29+
expect(toDialableHost('::')).toBe('localhost')
30+
expect(toDialableHost('127.0.0.1')).toBe('localhost')
31+
expect(toDialableHost('')).toBe('localhost')
32+
})
33+
34+
it('preserves routable hosts', () => {
35+
expect(toDialableHost('example.com')).toBe('example.com')
36+
expect(toDialableHost('192.168.1.10')).toBe('192.168.1.10')
37+
expect(toDialableHost('::1')).toBe('::1')
38+
})
39+
})
40+
41+
describe('formatHostForUrl', () => {
42+
it('brackets ipv6 but leaves ipv4 / hostnames bare', () => {
43+
expect(formatHostForUrl('::1')).toBe('[::1]')
44+
expect(formatHostForUrl('example.com')).toBe('example.com')
45+
expect(formatHostForUrl('0.0.0.0')).toBe('localhost')
46+
})
1647
})

packages/devframe/src/node/server.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { attachWsRpcTransport } from 'devframe/rpc/transports/ws-server'
1212
import { H3, toNodeHandler } from 'h3'
1313
import { diagnostics } from './diagnostics'
1414
import { getInternalContext } from './hub-internals/context'
15+
import { formatHostForUrl, normalizeHttpServerUrl } from './utils'
1516

1617
export interface StartHttpAndWsOptions {
1718
context: DevframeNodeContext
@@ -260,13 +261,16 @@ export async function startHttpAndWs(options: StartHttpAndWsOptions): Promise<St
260261

261262
const address = httpServer.address()
262263
const resolvedPort = typeof address === 'object' && address ? address.port : port
263-
const origin = `http://${bindHost}:${resolvedPort}`
264+
// Advertise a dialable origin: a wildcard bind host (`0.0.0.0` / `::`) is not
265+
// reachable from a browser, so the URL a client opens falls back to loopback
266+
// even though the socket keeps listening on every interface.
267+
const origin = normalizeHttpServerUrl(bindHost, resolvedPort)
264268
const internal = getInternalContext(context)
265269
// Record the full WS URL (including the bound route) so consumers like the
266270
// hub docks host can hand remote iframes a complete endpoint. A dedicated WS
267271
// port is reflected here so the URL stays dialable.
268272
const wsPortForUrl = separateWsPort ?? resolvedPort
269-
const wsUrl = `ws://${bindHost}:${wsPortForUrl}${options.path ?? ''}`
273+
const wsUrl = `ws://${formatHostForUrl(bindHost)}:${wsPortForUrl}${options.path ?? ''}`
270274
internal.wsEndpoint = {
271275
url: wsUrl,
272276
}

packages/devframe/src/node/utils.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,29 @@ export function isObject(value: unknown): value is Record<string, any> {
44
return Object.prototype.toString.call(value) === '[object Object]'
55
}
66

7-
export function normalizeHttpServerUrl(host: string, port: number | string): string {
8-
const normalizedHost
9-
= host === '127.0.0.1'
10-
? 'localhost'
11-
: isIP(host) === 6
12-
? `[${host}]`
13-
: host
7+
// Wildcard bind addresses (`0.0.0.0` / `::`) mean "listen on every interface";
8+
// they are not themselves dialable from a browser. When advertising a URL for a
9+
// client to open (banner, browser-open, dock entries), fall back to loopback —
10+
// the same thing Vite and friends do when bound to `--host 0.0.0.0`.
11+
const NON_DIALABLE_HOSTS = new Set([
12+
'0.0.0.0',
13+
'127.0.0.1',
14+
'::',
15+
'0000:0000:0000:0000:0000:0000:0000:0000',
16+
'', // an empty host binds to all interfaces too
17+
])
18+
19+
/** Map a bind host to a host a client can actually connect to. */
20+
export function toDialableHost(host: string): string {
21+
return NON_DIALABLE_HOSTS.has(host) ? 'localhost' : host
22+
}
1423

15-
return `http://${normalizedHost}:${port}`
24+
/** Format a bind host for use in a URL authority (dialable, IPv6-bracketed). */
25+
export function formatHostForUrl(host: string): string {
26+
const dialable = toDialableHost(host)
27+
return isIP(dialable) === 6 ? `[${dialable}]` : dialable
28+
}
29+
30+
export function normalizeHttpServerUrl(host: string, port: number | string): string {
31+
return `http://${formatHostForUrl(host)}:${port}`
1632
}

packages/hub/src/node/__tests__/context.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,10 @@ describe('startHttpAndWs remote endpoint metadata', () => {
5858
port: 0,
5959
})
6060

61+
// The advertised WS endpoint is dialable: the loopback IP normalizes to
62+
// `localhost`, matching the HTTP origin's normalization.
6163
expect(getInternalContext(context).wsEndpoint).toEqual({
62-
url: `ws://127.0.0.1:${started.port}`,
64+
url: `ws://localhost:${started.port}`,
6365
})
6466

6567
await started.close()

tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,10 @@ export declare function createRpcSharedStateServerHost(_: RpcFunctionsHost$1): R
8686
export declare function createRpcStreamingServerHost(_: RpcFunctionsHost$1): RpcStreamingHost;
8787
export declare function createScopedNodeContext<NS extends string = string>(_: DevframeNodeContext, _: NS): DevframeScopedNodeContext<NS>;
8888
export declare function createStorage<T extends object>(_: CreateStorageOptions<T>): SharedState<T>;
89+
export declare function formatHostForUrl(_: string): string;
8990
export declare function isObject(_: unknown): value is Record<string, any>;
9091
export declare function normalizeHttpServerUrl(_: string, _: number | string): string;
92+
export declare function toDialableHost(_: string): string;
9193
// #endregion
9294

9395
// #region Other
Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,6 @@
11
/**
22
* Generated by tsnapi — public API snapshot of `devframe/node`
33
*/
4-
// #region Functions
5-
export function isObject(_) {}
6-
export function normalizeHttpServerUrl(_, _) {}
7-
// #endregion
8-
94
// #region Other
105
export { createH3DevframeHost }
116
export { createHostContext }
@@ -17,6 +12,10 @@ export { createStorage }
1712
export { DevframeAgentHost }
1813
export { DevframeDiagnosticsHost }
1914
export { DevframeViewHost }
15+
export { formatHostForUrl }
16+
export { isObject }
17+
export { normalizeHttpServerUrl }
2018
export { RpcFunctionsHost }
2119
export { startHttpAndWs }
20+
export { toDialableHost }
2221
// #endregion

0 commit comments

Comments
 (0)