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
39 changes: 38 additions & 1 deletion packages/devframe/src/adapters/__tests__/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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({
Expand Down
5 changes: 4 additions & 1 deletion packages/devframe/src/adapters/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
33 changes: 32 additions & 1 deletion packages/devframe/src/node/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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')
})
})
8 changes: 6 additions & 2 deletions packages/devframe/src/node/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -260,13 +261,16 @@ export async function startHttpAndWs(options: StartHttpAndWsOptions): Promise<St

const address = httpServer.address()
const resolvedPort = typeof address === 'object' && address ? address.port : port
const origin = `http://${bindHost}:${resolvedPort}`
// Advertise a dialable origin: a wildcard bind host (`0.0.0.0` / `::`) is not
// reachable from a browser, so the URL a client opens falls back to loopback
// even though the socket keeps listening on every interface.
const origin = normalizeHttpServerUrl(bindHost, resolvedPort)
const internal = getInternalContext(context)
// Record the full WS URL (including the bound route) so consumers like the
// hub docks host can hand remote iframes a complete endpoint. A dedicated WS
// port is reflected here so the URL stays dialable.
const wsPortForUrl = separateWsPort ?? resolvedPort
const wsUrl = `ws://${bindHost}:${wsPortForUrl}${options.path ?? ''}`
const wsUrl = `ws://${formatHostForUrl(bindHost)}:${wsPortForUrl}${options.path ?? ''}`
internal.wsEndpoint = {
url: wsUrl,
}
Expand Down
32 changes: 24 additions & 8 deletions packages/devframe/src/node/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,29 @@ export function isObject(value: unknown): value is Record<string, any> {
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}`
}
4 changes: 3 additions & 1 deletion packages/hub/src/node/__tests__/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,10 @@ export declare function createRpcSharedStateServerHost(_: RpcFunctionsHost$1): R
export declare function createRpcStreamingServerHost(_: RpcFunctionsHost$1): RpcStreamingHost;
export declare function createScopedNodeContext<NS extends string = string>(_: DevframeNodeContext, _: NS): DevframeScopedNodeContext<NS>;
export declare function createStorage<T extends object>(_: CreateStorageOptions<T>): SharedState<T>;
export declare function formatHostForUrl(_: string): string;
export declare function isObject(_: unknown): value is Record<string, any>;
export declare function normalizeHttpServerUrl(_: string, _: number | string): string;
export declare function toDialableHost(_: string): string;
// #endregion

// #region Other
Expand Down
9 changes: 4 additions & 5 deletions tests/__snapshots__/tsnapi/devframe/node.snapshot.js
Original file line number Diff line number Diff line change
@@ -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 }
Expand All @@ -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
Loading