From ed514166f3a54cb8da1a8d564038631105bb558e Mon Sep 17 00:00:00 2001 From: DBAAZzz <1292094030@qq.com> Date: Mon, 26 Oct 2026 11:11:01 +0800 Subject: [PATCH 1/2] fix(devframe): skip ports held on another loopback address --- .../src/adapters/__tests__/dev.test.ts | 33 ++++++ patches/get-port-please@3.2.0.patch | 102 ++++++++++++++++++ pnpm-lock.yaml | 27 ++--- pnpm-workspace.yaml | 1 + 4 files changed, 150 insertions(+), 13 deletions(-) create mode 100644 patches/get-port-please@3.2.0.patch diff --git a/packages/devframe/src/adapters/__tests__/dev.test.ts b/packages/devframe/src/adapters/__tests__/dev.test.ts index 98c0da323..8ce578c40 100644 --- a/packages/devframe/src/adapters/__tests__/dev.test.ts +++ b/packages/devframe/src/adapters/__tests__/dev.test.ts @@ -1,5 +1,6 @@ import type { DevframeNodeContext, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from '../../types' import { mkdtempSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:net' import { tmpdir } from 'node:os' import { join } from 'node:path' import { defineDevframe } from 'devframe' @@ -32,6 +33,15 @@ function makeTmpDist(): string { return dir } +// Holds a port on one address, like an unrelated server already running. +function listenOn(port: number, host: string): Promise> { + return new Promise((resolve, reject) => { + const server = createServer() + server.once('error', reject) + server.listen(port, host, () => resolve(server)) + }) +} + async function connectRaw(url: string, origin?: string): Promise<'open' | 'closed'> { return await new Promise((resolve) => { const ws = new WebSocket(url, origin ? { headers: { origin } } : undefined) @@ -763,6 +773,29 @@ describe('adapters/dev', () => { expect(port).toBe(override) }) + it('createDevServer on the default host does not share a port held on 127.0.0.1', async () => { + const taken = await getPort({ port: 19660, host: '127.0.0.1' }) + const blocker = await listenOn(taken, '127.0.0.1') + const devframe = defineDevframe({ + id: 'devframe-test-port-shared', + name: 'Port Shared', + version: '0.0.0', + packageName: 'devframe-test', + homepage: 'https://example.test', + description: 'Test devframe.', + setup: () => {}, + cli: { port: taken }, + }) + const handle = await createDevServer(devframe, { openBrowser: false, distDir: makeTmpDist() }) + try { + expect(handle.port).not.toBe(taken) + } + finally { + await handle.close() + blocker.close() + } + }) + it('registers the instance in the registry and unregisters on close', async () => { const registryDir = mkdtempSync(join(tmpdir(), 'devframe-registry-')) vi.stubEnv('DEVFRAME_INSTANCES_DIR', registryDir) diff --git a/patches/get-port-please@3.2.0.patch b/patches/get-port-please@3.2.0.patch new file mode 100644 index 000000000..36646cc40 --- /dev/null +++ b/patches/get-port-please@3.2.0.patch @@ -0,0 +1,102 @@ +diff --git a/dist/index.cjs b/dist/index.cjs +index 31985b25727b3c09416c45d4763d0f83706317b4..fbbbdd63c8126f4f9dfae14a2e486bdd1e1a6b26 100644 +--- a/dist/index.cjs ++++ b/dist/index.cjs +@@ -211,6 +211,37 @@ function _tryPort(port, host) { + }); + }); + } ++// Addresses that can answer for `localhost`. They can each hold the same port ++// independently (e.g. `::1` while another process holds `127.0.0.1`), so a ++// port is only free for a loopback or wildcard host if none of them has it. ++const LOOPBACK_HOSTS = ["127.0.0.1", "::1", "0.0.0.0", "::"]; ++function _isLoopbackHost(host) { ++ return !host || host === "localhost" || LOOPBACK_HOSTS.includes(host); ++} ++function _isPortInUse(port, host) { ++ return new Promise((resolve) => { ++ const server = node_net.createServer(); ++ server.unref(); ++ server.on("error", (error) => { ++ resolve(error.code === "EADDRINUSE" || error.code === "EACCES"); ++ }); ++ server.listen({ port, host }, () => { ++ server.close(() => resolve(false)); ++ }); ++ }); ++} ++async function _tryPortOnLoopback(port, host) { ++ const result = await _tryPort(port, host); ++ if (result === false || !_isLoopbackHost(host)) { ++ return result; ++ } ++ for (const loopbackHost of LOOPBACK_HOSTS) { ++ if (loopbackHost !== host && await _isPortInUse(result, loopbackHost)) { ++ return false; ++ } ++ } ++ return result; ++} + function _getLocalHosts(additional) { + const hosts = new Set(additional); + for (const _interface of Object.values(node_os.networkInterfaces())) { +@@ -225,7 +256,7 @@ function _getLocalHosts(additional) { + } + async function _findPort(ports, host) { + for (const port of ports) { +- const r = await _tryPort(port, host); ++ const r = await _tryPortOnLoopback(port, host); + if (r) { + return r; + } +diff --git a/dist/index.mjs b/dist/index.mjs +index d3f84762fa155ffa1119f5bc5e8bb7e0aeb0b114..181a0c588ba9a438a478492f7b0a219e911cc425 100644 +--- a/dist/index.mjs ++++ b/dist/index.mjs +@@ -209,6 +209,37 @@ function _tryPort(port, host) { + }); + }); + } ++// Addresses that can answer for `localhost`. They can each hold the same port ++// independently (e.g. `::1` while another process holds `127.0.0.1`), so a ++// port is only free for a loopback or wildcard host if none of them has it. ++const LOOPBACK_HOSTS = ["127.0.0.1", "::1", "0.0.0.0", "::"]; ++function _isLoopbackHost(host) { ++ return !host || host === "localhost" || LOOPBACK_HOSTS.includes(host); ++} ++function _isPortInUse(port, host) { ++ return new Promise((resolve) => { ++ const server = createServer(); ++ server.unref(); ++ server.on("error", (error) => { ++ resolve(error.code === "EADDRINUSE" || error.code === "EACCES"); ++ }); ++ server.listen({ port, host }, () => { ++ server.close(() => resolve(false)); ++ }); ++ }); ++} ++async function _tryPortOnLoopback(port, host) { ++ const result = await _tryPort(port, host); ++ if (result === false || !_isLoopbackHost(host)) { ++ return result; ++ } ++ for (const loopbackHost of LOOPBACK_HOSTS) { ++ if (loopbackHost !== host && await _isPortInUse(result, loopbackHost)) { ++ return false; ++ } ++ } ++ return result; ++} + function _getLocalHosts(additional) { + const hosts = new Set(additional); + for (const _interface of Object.values(networkInterfaces())) { +@@ -223,7 +254,7 @@ function _getLocalHosts(additional) { + } + async function _findPort(ports, host) { + for (const port of ports) { +- const r = await _tryPort(port, host); ++ const r = await _tryPortOnLoopback(port, host); + if (r) { + return r; + } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7feed2861..8b8e02fbd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -599,6 +599,7 @@ overrides: patchedDependencies: comark-docs@0.0.1: 5f1078be206123f957de74141ae02eaae510555cc7444404bffbfaae8d407e33 + get-port-please@3.2.0: f43f9c8f88f0cd1126a5c319226c9c345e89215b8f51f87c8650af96bc87953a importers: @@ -891,7 +892,7 @@ importers: version: 66.10.5(postcss@8.5.28) get-port-please: specifier: catalog:deps - version: 3.2.0 + version: 3.2.0(patch_hash=f43f9c8f88f0cd1126a5c319226c9c345e89215b8f51f87c8650af96bc87953a) pathe: specifier: catalog:deps version: 2.0.3 @@ -1020,7 +1021,7 @@ importers: version: 2.10.6(@babel/core@7.29.7(supports-color@10.2.2))(preact@11.0.0)(rollup@4.64.0)(supports-color@10.2.2)(vite@8.3.3) get-port-please: specifier: catalog:deps - version: 3.2.0 + version: 3.2.0(patch_hash=f43f9c8f88f0cd1126a5c319226c9c345e89215b8f51f87c8650af96bc87953a) h3: specifier: catalog:deps version: 2.0.1(crossws@0.4.12(srvx@1.0.5))(ocache@0.3.0) @@ -1527,7 +1528,7 @@ importers: version: 66.10.5(postcss@8.5.28) get-port-please: specifier: catalog:deps - version: 3.2.0 + version: 3.2.0(patch_hash=f43f9c8f88f0cd1126a5c319226c9c345e89215b8f51f87c8650af96bc87953a) h3: specifier: catalog:deps version: 2.0.1(crossws@0.4.12(srvx@1.0.5))(ocache@0.3.0) @@ -1592,7 +1593,7 @@ importers: version: 2.10.6(@babel/core@7.29.7(supports-color@10.2.2))(preact@11.0.0)(rollup@4.64.0)(supports-color@10.2.2)(vite@8.3.3) get-port-please: specifier: catalog:deps - version: 3.2.0 + version: 3.2.0(patch_hash=f43f9c8f88f0cd1126a5c319226c9c345e89215b8f51f87c8650af96bc87953a) h3: specifier: catalog:deps version: 2.0.1(crossws@0.4.12(srvx@1.0.5))(ocache@0.3.0) @@ -1669,7 +1670,7 @@ importers: version: 7.0.0 get-port-please: specifier: catalog:deps - version: 3.2.0 + version: 3.2.0(patch_hash=f43f9c8f88f0cd1126a5c319226c9c345e89215b8f51f87c8650af96bc87953a) immer: specifier: catalog:deps version: 11.1.21 @@ -1751,7 +1752,7 @@ importers: version: link:../devframe get-port-please: specifier: catalog:deps - version: 3.2.0 + version: 3.2.0(patch_hash=f43f9c8f88f0cd1126a5c319226c9c345e89215b8f51f87c8650af96bc87953a) tsdown: specifier: catalog:build version: 0.23.0(@vitejs/devtools@0.7.6)(@volar/typescript@2.4.28(typescript@6.0.3))(@vue/language-core@3.3.12)(oxc-resolver@11.24.2)(tsx@4.23.15)(typescript@6.0.3) @@ -2023,7 +2024,7 @@ importers: version: link:../devframe get-port-please: specifier: catalog:deps - version: 3.2.0 + version: 3.2.0(patch_hash=f43f9c8f88f0cd1126a5c319226c9c345e89215b8f51f87c8650af96bc87953a) tsdown: specifier: catalog:build version: 0.23.0(@vitejs/devtools@0.7.6)(@volar/typescript@2.4.28(typescript@6.0.3))(@vue/language-core@3.3.12)(oxc-resolver@11.24.2)(tsx@4.23.15)(typescript@6.0.3) @@ -2837,7 +2838,7 @@ importers: version: 1.2.2 get-port-please: specifier: catalog:deps - version: 3.2.0 + version: 3.2.0(patch_hash=f43f9c8f88f0cd1126a5c319226c9c345e89215b8f51f87c8650af96bc87953a) pathe: specifier: catalog:deps version: 2.0.3 @@ -14274,7 +14275,7 @@ snapshots: escape-string-regexp: 5.0.0 exsolve: 1.1.3 generic-names: 4.0.0 - get-port-please: 3.2.0 + get-port-please: 3.2.0(patch_hash=f43f9c8f88f0cd1126a5c319226c9c345e89215b8f51f87c8650af96bc87953a) jiti: 2.7.0 js-tokens: 10.0.0 knitwork: 1.3.0 @@ -14344,7 +14345,7 @@ snapshots: escape-string-regexp: 5.0.0 exsolve: 1.1.3 generic-names: 4.0.0 - get-port-please: 3.2.0 + get-port-please: 3.2.0(patch_hash=f43f9c8f88f0cd1126a5c319226c9c345e89215b8f51f87c8650af96bc87953a) jiti: 2.7.0 js-tokens: 10.0.0 knitwork: 1.3.0 @@ -18767,7 +18768,7 @@ snapshots: hasown: 2.0.4 math-intrinsics: 1.1.0 - get-port-please@3.2.0: {} + get-port-please@3.2.0(patch_hash=f43f9c8f88f0cd1126a5c319226c9c345e89215b8f51f87c8650af96bc87953a): {} get-proto@1.0.1: dependencies: @@ -19390,7 +19391,7 @@ snapshots: consola: 3.4.2 crossws: 0.4.12(srvx@0.11.22) defu: 6.1.7 - get-port-please: 3.2.0 + get-port-please: 3.2.0(patch_hash=f43f9c8f88f0cd1126a5c319226c9c345e89215b8f51f87c8650af96bc87953a) h3: 1.15.11(srvx@0.11.22) http-shutdown: 1.2.2 jiti: 2.7.0 @@ -19411,7 +19412,7 @@ snapshots: consola: 3.4.2 crossws: 0.4.12(srvx@1.0.5) defu: 6.1.7 - get-port-please: 3.2.0 + get-port-please: 3.2.0(patch_hash=f43f9c8f88f0cd1126a5c319226c9c345e89215b8f51f87c8650af96bc87953a) h3: 1.15.11(srvx@1.0.5) http-shutdown: 1.2.2 jiti: 2.7.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f0c5574c7..aea00e940 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -104,6 +104,7 @@ overrides: patchedDependencies: comark-docs@0.0.1: patches/comark-docs@0.0.1.patch + get-port-please@3.2.0: patches/get-port-please@3.2.0.patch allowBuilds: '@parcel/watcher': false From 873529d6386c66e50d5e76494313900dc7f9b440 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 30 Oct 2026 21:26:33 +0000 Subject: [PATCH 2/2] fix(devframe): listen on both loopback addresses for the dev server Drop the `get-port-please` patch. `createDevServer` on `localhost` now also listens on the other loopback address, like the side-car does since `EADDRINUSE` and a port that was not pinned moves to the next free one. A pinned `port` / `--port` still fails with `DF0052`. Co-Authored-By: Claude Opus 5.5 --- .../src/adapters/__tests__/dev.test.ts | 27 +++++ packages/devframe/src/adapters/cac.ts | 8 +- packages/devframe/src/adapters/dev.ts | 67 ++++++++---- packages/devframe/src/node/instance-shell.ts | 49 +-------- packages/devframe/src/node/listen.ts | 56 ++++++++++ patches/get-port-please@3.2.0.patch | 102 ------------------ pnpm-lock.yaml | 29 +++-- pnpm-workspace.yaml | 1 - .../devframe/adapters/dev.snapshot.d.ts | 1 + 9 files changed, 148 insertions(+), 192 deletions(-) create mode 100644 packages/devframe/src/node/listen.ts delete mode 100644 patches/get-port-please@3.2.0.patch diff --git a/packages/devframe/src/adapters/__tests__/dev.test.ts b/packages/devframe/src/adapters/__tests__/dev.test.ts index 8ce578c40..964340077 100644 --- a/packages/devframe/src/adapters/__tests__/dev.test.ts +++ b/packages/devframe/src/adapters/__tests__/dev.test.ts @@ -789,6 +789,11 @@ describe('adapters/dev', () => { const handle = await createDevServer(devframe, { openBrowser: false, distDir: makeTmpDist() }) try { expect(handle.port).not.toBe(taken) + // A page on either loopback address reaches this dev server. + for (const host of ['127.0.0.1', '[::1]']) { + const res = await fetch(`http://${host}:${handle.port}/__connection.json`) + expect(res.status).toBe(200) + } } finally { await handle.close() @@ -796,6 +801,28 @@ describe('adapters/dev', () => { } }) + it('createDevServer fails on a pinned port held on 127.0.0.1', async () => { + const taken = await getPort({ port: 19670, host: '127.0.0.1' }) + const blocker = await listenOn(taken, '127.0.0.1') + const devframe = defineDevframe({ + id: 'devframe-test-port-pinned', + name: 'Port Pinned', + version: '0.0.0', + packageName: 'devframe-test', + homepage: 'https://example.test', + description: 'Test devframe.', + setup: () => {}, + }) + try { + await expect(createDevServer(devframe, { port: taken, openBrowser: false, distDir: makeTmpDist() })) + .rejects + .toMatchObject({ code: 'DF0052' }) + } + finally { + blocker.close() + } + }) + it('registers the instance in the registry and unregisters on close', async () => { const registryDir = mkdtempSync(join(tmpdir(), 'devframe-registry-')) vi.stubEnv('DEVFRAME_INSTANCES_DIR', registryDir) diff --git a/packages/devframe/src/adapters/cac.ts b/packages/devframe/src/adapters/cac.ts index 52d209bb9..538324c8d 100644 --- a/packages/devframe/src/adapters/cac.ts +++ b/packages/devframe/src/adapters/cac.ts @@ -14,7 +14,7 @@ import cac from 'cac' import { colors as c } from 'devframe/utils/colors' import { importAgenticMcp } from '../node/agentic' import { createBuild } from './build' -import { createDevServer, resolveDevServerPort } from './dev' +import { createDevServer } from './dev' import { flagKeyToOption, isBooleanFlag, parseCliFlags } from './flags' export { defineCliFlags, parseCliFlags } from './flags' @@ -103,7 +103,6 @@ export function createCac(d: DevframeDefinition, options: CreateCacOptions = {}) devCommand.action(async (_args: unknown, rawFlags: CliFlags) => { const flags = resolveTypedFlags(d, rawFlags) as CliFlags const host = (flags.host as string | undefined) ?? defaultHost - const port = (flags.port as number | undefined) ?? await resolveDevServerPort(d, { host, defaultPort }) // `--mcp` / `--no-mcp` map to a boolean override; when neither is passed // CAC leaves `mcp` undefined so we fall back to the assembly-stage // `options.mcp`, and `createDevServer` falls through to the `'auto'` @@ -111,7 +110,10 @@ export function createCac(d: DevframeDefinition, options: CreateCacOptions = {}) const mcp = (flags.mcp as boolean | undefined) ?? options.mcp await createDevServer(d, { host, - port, + // A `--port` flag pins the port; otherwise `createDevServer` resolves + // one and can move off a port it finds taken. + port: flags.port as number | undefined, + defaultPort, flags, mcp, onReady: options.onReady, diff --git a/packages/devframe/src/adapters/dev.ts b/packages/devframe/src/adapters/dev.ts index 2c5114ccc..12d5eaf54 100644 --- a/packages/devframe/src/adapters/dev.ts +++ b/packages/devframe/src/adapters/dev.ts @@ -1,4 +1,5 @@ import type { DevframeRpcConnection, WsOriginRegistry } from 'devframe/rpc/transports/ws-server' +import type { Server } from 'node:http' import type { DevframeAuthHandler } from '../node/auth/handler' import type { StartedServer } from '../node/instance-shell' import type { DevframeDefinition, DevframeSseOptions, DevframeWsOptions, McpSetting } from '../types/devframe' @@ -9,6 +10,7 @@ import { open } from 'devframe/utils/open' import { withBase } from 'devframe/utils/url' import { H3, toNodeHandler } from 'h3' import { diagnostics } from '../node/diagnostics' +import { isAddressInUse, listenWithLoopbackTwin } from '../node/listen' import { normalizeHttpServerUrl } from '../node/utils' import { normalizeBasePath, resolveBasePath, resolveDevServerPort } from './_shared' import { getInstanceInternals, initDevframe } from './initiate' @@ -22,9 +24,15 @@ export interface CreateDevServerOptions { /** * Port to listen on. When omitted, falls back to * {@link resolveDevServerPort}, which respects `def.cli?.port` / - * `portRange` / `random`. + * `portRange` / `random`. A port set here is used as given: if it is + * taken, startup fails (`DF0052`) instead of moving to another port. */ port?: number + /** + * Preferred port when `port` is omitted, passed to + * {@link resolveDevServerPort}. Default: `def.cli?.port ?? 9999`. + */ + defaultPort?: number /** * Parsed flag bag forwarded to `setup(ctx, { flags })`. The dev * server itself only reads `flags.open` from this bag, and only when @@ -147,7 +155,6 @@ export async function createDevServer( throw diagnostics.DF0058({ id: def.id }) const host = options.host ?? def.cli?.host ?? 'localhost' - const requestedPort = options.port ?? await resolveDevServerPort(def, { host }) const flags = options.flags ?? {} const basePath = options.basePath ? normalizeBasePath(options.basePath) : resolveBasePath(def, 'standalone') const app = options.app ?? new H3() @@ -157,27 +164,7 @@ export async function createDevServer( // starts first so the shared WS tier and advertised origin reflect the real // bound port (`port: 0` binds an ephemeral one). const server = createServer(toNodeHandler(app)) - try { - await new Promise((resolveListen, rejectListen) => { - const onError = (error: Error): void => rejectListen(error) - // Without this listener a failed bind emits `error` with nobody - // attached (an uncaughtException) and the `listen` callback never - // fires, so this promise never settles. - server.once('error', onError) - server.listen(requestedPort, host, () => { - server.removeListener('error', onError) - resolveListen() - }) - }) - } - catch (error) { - throw diagnostics.DF0052({ - host, - port: requestedPort, - reason: error instanceof Error ? error.message : String(error), - cause: error, - }) - } + const { requestedPort, closeLoopbackTwin } = await listenDevServer(server, def, host, options) const address = server.address() const port = typeof address === 'object' && address ? address.port : requestedPort // A wildcard bind host (`0.0.0.0` / `::`) isn't dialable from a browser, so @@ -220,6 +207,7 @@ export async function createDevServer( await devframe.ready } catch (error) { + await closeLoopbackTwin() await new Promise(resolveClose => server.close(() => resolveClose())) throw error } @@ -244,11 +232,44 @@ export async function createDevServer( // transport (and closes any dedicated-port socket server), and disposes // MCP sessions; the HTTP server is this function's own to close. await devframe.close() + await closeLoopbackTwin() await new Promise(resolveClose => server.close(() => resolveClose())) }, } } +/** + * Bind the dev server. `getPort` probes only `host`, so it can call a port + * free while another process holds it on the other loopback address of + * `localhost`. The twin bind catches that, and a port that was not pinned + * moves to the next free one. + */ +async function listenDevServer( + server: Server, + def: DevframeDefinition, + host: string, + options: CreateDevServerOptions, +): Promise<{ requestedPort: number, closeLoopbackTwin: () => Promise }> { + let requestedPort = options.port ?? await resolveDevServerPort(def, { host, defaultPort: options.defaultPort }) + for (let attempt = 1; ; attempt++) { + try { + return { requestedPort, closeLoopbackTwin: await listenWithLoopbackTwin(server, requestedPort, host) } + } + catch (error) { + if (options.port == null && attempt < 3 && isAddressInUse(error)) { + requestedPort = await resolveDevServerPort(def, { host, defaultPort: requestedPort + 1 }) + continue + } + throw diagnostics.DF0052({ + host, + port: requestedPort, + reason: error instanceof Error ? error.message : String(error), + cause: error, + }) + } + } +} + async function maybeOpenBrowser( def: DevframeDefinition, flags: Record, diff --git a/packages/devframe/src/node/instance-shell.ts b/packages/devframe/src/node/instance-shell.ts index bc445db2c..53b7c9db1 100644 --- a/packages/devframe/src/node/instance-shell.ts +++ b/packages/devframe/src/node/instance-shell.ts @@ -22,6 +22,7 @@ import { DEVFRAME_SSE_ROUTE, DEVFRAME_WS_ROUTE } from '../constants' import { createInteractiveAuth } from '../recipes/interactive-auth' import { diagnostics } from './diagnostics' import { getInternalContext } from './hub-internals/context' +import { listenWithLoopbackTwin } from './listen' import { detectServerRuntime } from './runtime' import { formatHostForUrl, normalizeHttpServerUrl } from './utils' @@ -82,54 +83,6 @@ function websocketConnectionMeta(rpcHost: RpcFunctionsHostImpl, path: string | u return { backend: 'websocket', websocket: { path }, jsonSerializableMethods } } -function listen(server: NodeHttpServer, port: number, host: string): Promise { - return new Promise((resolve, reject) => { - const onError = (error: Error): void => reject(error) - // Without this listener a failed bind emits `error` with nobody - // attached (an uncaughtException) and the `listen` callback never - // fires, so this promise never settles. - server.once('error', onError) - server.listen(port, host, () => { - server.removeListener('error', onError) - resolve() - }) - }) -} - -/** Errors meaning the machine has no such loopback address to bind. */ -const NO_LOOPBACK_ADDRESS = new Set(['EADDRNOTAVAIL', 'EAFNOSUPPORT']) - -/** - * Listen on `host`, and when that is `localhost`, on the other loopback - * address too. `listen(port, 'localhost')` binds only the first address - * `localhost` resolves to (`::1` on most current Linux and macOS setups), - * while a client dials the side-car on its own page's hostname, so a page - * opened on `127.0.0.1` would be refused. The twin server hands its traffic - * to `server`. If another process holds the twin address, fail like a taken - * port, so pages on that address never reach a different server. A machine - * without that address family skips the twin. Resolves to a closer for the - * twin. - */ -async function listenWithLoopbackTwin(server: NodeHttpServer, port: number, host: string): Promise<() => Promise> { - await listen(server, port, host) - const bound = server.address() - if (host !== 'localhost' || !bound || typeof bound === 'string') - return async () => {} - const twin = createServer() - twin.on('request', (req, res) => server.emit('request', req, res)) - twin.on('upgrade', (req, socket, head) => server.emit('upgrade', req, socket, head)) - try { - await listen(twin, bound.port, bound.family === 'IPv6' ? '127.0.0.1' : '::1') - return () => new Promise(r => twin.close(() => r())) - } - catch (error) { - if (NO_LOOPBACK_ADDRESS.has((error as NodeJS.ErrnoException).code ?? '')) - return async () => {} - await new Promise(r => server.close(() => r())) - throw error - } -} - /** * Compose an h3 + WebSocket RPC server for a devframe context: the low-level * "listen on a port (or share one) + attach the WS transport" binding the diff --git a/packages/devframe/src/node/listen.ts b/packages/devframe/src/node/listen.ts new file mode 100644 index 000000000..ada5e4c64 --- /dev/null +++ b/packages/devframe/src/node/listen.ts @@ -0,0 +1,56 @@ +import type { Server as NodeHttpServer } from 'node:http' +import { createServer } from 'node:http' + +function listen(server: NodeHttpServer, port: number, host: string): Promise { + return new Promise((resolve, reject) => { + const onError = (error: Error): void => reject(error) + // Without this listener a failed bind emits `error` with nobody + // attached (an uncaughtException) and the `listen` callback never + // fires, so this promise never settles. + server.once('error', onError) + server.listen(port, host, () => { + server.removeListener('error', onError) + resolve() + }) + }) +} + +/** Errors meaning the machine has no such loopback address to bind. */ +const NO_LOOPBACK_ADDRESS = new Set(['EADDRNOTAVAIL', 'EAFNOSUPPORT']) + +/** Whether a failed bind means another process holds the port. */ +export function isAddressInUse(error: unknown): boolean { + return (error as NodeJS.ErrnoException | undefined)?.code === 'EADDRINUSE' +} + +/** + * Listen on `host`, and when that is `localhost`, on the other loopback + * address too. `listen(port, 'localhost')` binds only the first address + * `localhost` resolves to (`::1` on most current Linux and macOS setups), + * while a browser dials the page's own hostname, so a page opened on + * `127.0.0.1` would be refused, or reach another process that holds + * `127.0.0.1` on the same port. The twin server hands its traffic to + * `server`. If another process holds the twin address, fail like a taken + * port (`EADDRINUSE`), so pages on that address never reach a different + * server. A machine without that address family skips the twin. Resolves to + * a closer for the twin. + */ +export async function listenWithLoopbackTwin(server: NodeHttpServer, port: number, host: string): Promise<() => Promise> { + await listen(server, port, host) + const bound = server.address() + if (host !== 'localhost' || !bound || typeof bound === 'string') + return async () => {} + const twin = createServer() + twin.on('request', (req, res) => server.emit('request', req, res)) + twin.on('upgrade', (req, socket, head) => server.emit('upgrade', req, socket, head)) + try { + await listen(twin, bound.port, bound.family === 'IPv6' ? '127.0.0.1' : '::1') + return () => new Promise(r => twin.close(() => r())) + } + catch (error) { + if (NO_LOOPBACK_ADDRESS.has((error as NodeJS.ErrnoException).code ?? '')) + return async () => {} + await new Promise(r => server.close(() => r())) + throw error + } +} diff --git a/patches/get-port-please@3.2.0.patch b/patches/get-port-please@3.2.0.patch deleted file mode 100644 index 36646cc40..000000000 --- a/patches/get-port-please@3.2.0.patch +++ /dev/null @@ -1,102 +0,0 @@ -diff --git a/dist/index.cjs b/dist/index.cjs -index 31985b25727b3c09416c45d4763d0f83706317b4..fbbbdd63c8126f4f9dfae14a2e486bdd1e1a6b26 100644 ---- a/dist/index.cjs -+++ b/dist/index.cjs -@@ -211,6 +211,37 @@ function _tryPort(port, host) { - }); - }); - } -+// Addresses that can answer for `localhost`. They can each hold the same port -+// independently (e.g. `::1` while another process holds `127.0.0.1`), so a -+// port is only free for a loopback or wildcard host if none of them has it. -+const LOOPBACK_HOSTS = ["127.0.0.1", "::1", "0.0.0.0", "::"]; -+function _isLoopbackHost(host) { -+ return !host || host === "localhost" || LOOPBACK_HOSTS.includes(host); -+} -+function _isPortInUse(port, host) { -+ return new Promise((resolve) => { -+ const server = node_net.createServer(); -+ server.unref(); -+ server.on("error", (error) => { -+ resolve(error.code === "EADDRINUSE" || error.code === "EACCES"); -+ }); -+ server.listen({ port, host }, () => { -+ server.close(() => resolve(false)); -+ }); -+ }); -+} -+async function _tryPortOnLoopback(port, host) { -+ const result = await _tryPort(port, host); -+ if (result === false || !_isLoopbackHost(host)) { -+ return result; -+ } -+ for (const loopbackHost of LOOPBACK_HOSTS) { -+ if (loopbackHost !== host && await _isPortInUse(result, loopbackHost)) { -+ return false; -+ } -+ } -+ return result; -+} - function _getLocalHosts(additional) { - const hosts = new Set(additional); - for (const _interface of Object.values(node_os.networkInterfaces())) { -@@ -225,7 +256,7 @@ function _getLocalHosts(additional) { - } - async function _findPort(ports, host) { - for (const port of ports) { -- const r = await _tryPort(port, host); -+ const r = await _tryPortOnLoopback(port, host); - if (r) { - return r; - } -diff --git a/dist/index.mjs b/dist/index.mjs -index d3f84762fa155ffa1119f5bc5e8bb7e0aeb0b114..181a0c588ba9a438a478492f7b0a219e911cc425 100644 ---- a/dist/index.mjs -+++ b/dist/index.mjs -@@ -209,6 +209,37 @@ function _tryPort(port, host) { - }); - }); - } -+// Addresses that can answer for `localhost`. They can each hold the same port -+// independently (e.g. `::1` while another process holds `127.0.0.1`), so a -+// port is only free for a loopback or wildcard host if none of them has it. -+const LOOPBACK_HOSTS = ["127.0.0.1", "::1", "0.0.0.0", "::"]; -+function _isLoopbackHost(host) { -+ return !host || host === "localhost" || LOOPBACK_HOSTS.includes(host); -+} -+function _isPortInUse(port, host) { -+ return new Promise((resolve) => { -+ const server = createServer(); -+ server.unref(); -+ server.on("error", (error) => { -+ resolve(error.code === "EADDRINUSE" || error.code === "EACCES"); -+ }); -+ server.listen({ port, host }, () => { -+ server.close(() => resolve(false)); -+ }); -+ }); -+} -+async function _tryPortOnLoopback(port, host) { -+ const result = await _tryPort(port, host); -+ if (result === false || !_isLoopbackHost(host)) { -+ return result; -+ } -+ for (const loopbackHost of LOOPBACK_HOSTS) { -+ if (loopbackHost !== host && await _isPortInUse(result, loopbackHost)) { -+ return false; -+ } -+ } -+ return result; -+} - function _getLocalHosts(additional) { - const hosts = new Set(additional); - for (const _interface of Object.values(networkInterfaces())) { -@@ -223,7 +254,7 @@ function _getLocalHosts(additional) { - } - async function _findPort(ports, host) { - for (const port of ports) { -- const r = await _tryPort(port, host); -+ const r = await _tryPortOnLoopback(port, host); - if (r) { - return r; - } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf961dca9..e3bc054d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -602,7 +602,6 @@ overrides: patchedDependencies: comark-docs@0.0.1: 5f1078be206123f957de74141ae02eaae510555cc7444404bffbfaae8d407e33 - get-port-please@3.2.0: f43f9c8f88f0cd1126a5c319226c9c345e89215b8f51f87c8650af96bc87953a importers: @@ -898,7 +897,7 @@ importers: version: 66.11.0(postcss@8.5.31) get-port-please: specifier: catalog:deps - version: 3.2.0(patch_hash=f43f9c8f88f0cd1126a5c319226c9c345e89215b8f51f87c8650af96bc87953a) + version: 3.2.0 pathe: specifier: catalog:deps version: 2.0.3 @@ -1027,7 +1026,7 @@ importers: version: 2.10.6(@babel/core@7.29.7(supports-color@10.2.2))(preact@11.0.1)(rollup@4.64.0)(supports-color@10.2.2)(vite@8.3.4) get-port-please: specifier: catalog:deps - version: 3.2.0(patch_hash=f43f9c8f88f0cd1126a5c319226c9c345e89215b8f51f87c8650af96bc87953a) + version: 3.2.0 h3: specifier: catalog:deps version: 2.0.7(crossws@0.4.15(srvx@1.0.5))(ocache@0.3.0) @@ -1534,7 +1533,7 @@ importers: version: 66.11.0(postcss@8.5.31) get-port-please: specifier: catalog:deps - version: 3.2.0(patch_hash=f43f9c8f88f0cd1126a5c319226c9c345e89215b8f51f87c8650af96bc87953a) + version: 3.2.0 h3: specifier: catalog:deps version: 2.0.7(crossws@0.4.15(srvx@1.0.5))(ocache@0.3.0) @@ -1599,7 +1598,7 @@ importers: version: 2.10.6(@babel/core@7.29.7(supports-color@10.2.2))(preact@11.0.1)(rollup@4.64.0)(supports-color@10.2.2)(vite@8.3.4) get-port-please: specifier: catalog:deps - version: 3.2.0(patch_hash=f43f9c8f88f0cd1126a5c319226c9c345e89215b8f51f87c8650af96bc87953a) + version: 3.2.0 h3: specifier: catalog:deps version: 2.0.7(crossws@0.4.15(srvx@1.0.5))(ocache@0.3.0) @@ -1676,7 +1675,7 @@ importers: version: 7.1.0 get-port-please: specifier: catalog:deps - version: 3.2.0(patch_hash=f43f9c8f88f0cd1126a5c319226c9c345e89215b8f51f87c8650af96bc87953a) + version: 3.2.0 immer: specifier: catalog:deps version: 11.2.3 @@ -1758,7 +1757,7 @@ importers: version: link:../devframe get-port-please: specifier: catalog:deps - version: 3.2.0(patch_hash=f43f9c8f88f0cd1126a5c319226c9c345e89215b8f51f87c8650af96bc87953a) + version: 3.2.0 tsdown: specifier: catalog:build version: 0.23.1(@vitejs/devtools@0.7.6)(@volar/typescript@2.4.28(typescript@6.0.3))(@vue/language-core@3.3.12)(oxc-resolver@11.24.2)(tsx@4.23.15)(typescript@6.0.3) @@ -2030,7 +2029,7 @@ importers: version: link:../devframe get-port-please: specifier: catalog:deps - version: 3.2.0(patch_hash=f43f9c8f88f0cd1126a5c319226c9c345e89215b8f51f87c8650af96bc87953a) + version: 3.2.0 tsdown: specifier: catalog:build version: 0.23.1(@vitejs/devtools@0.7.6)(@volar/typescript@2.4.28(typescript@6.0.3))(@vue/language-core@3.3.12)(oxc-resolver@11.24.2)(tsx@4.23.15)(typescript@6.0.3) @@ -2844,7 +2843,7 @@ importers: version: 1.2.2 get-port-please: specifier: catalog:deps - version: 3.2.0(patch_hash=f43f9c8f88f0cd1126a5c319226c9c345e89215b8f51f87c8650af96bc87953a) + version: 3.2.0 pathe: specifier: catalog:deps version: 2.0.3 @@ -13912,7 +13911,7 @@ snapshots: defu: 6.1.7 exsolve: 1.1.3 fuzzysort: 4.0.2 - get-port-please: 3.2.0(patch_hash=f43f9c8f88f0cd1126a5c319226c9c345e89215b8f51f87c8650af96bc87953a) + get-port-please: 3.2.0 my-bad: 0.2.11(vite@8.3.4) obug: 3.0.0 package-manager-detector: 1.9.0 @@ -14638,7 +14637,7 @@ snapshots: escape-string-regexp: 5.0.0 exsolve: 1.1.3 generic-names: 4.0.0 - get-port-please: 3.2.0(patch_hash=f43f9c8f88f0cd1126a5c319226c9c345e89215b8f51f87c8650af96bc87953a) + get-port-please: 3.2.0 js-tokens: 10.0.0 knitwork: 1.3.0 mocked-exports: 0.1.1 @@ -14712,7 +14711,7 @@ snapshots: escape-string-regexp: 5.0.0 exsolve: 1.1.3 generic-names: 4.0.0 - get-port-please: 3.2.0(patch_hash=f43f9c8f88f0cd1126a5c319226c9c345e89215b8f51f87c8650af96bc87953a) + get-port-please: 3.2.0 js-tokens: 10.0.0 knitwork: 1.3.0 mocked-exports: 0.1.1 @@ -19455,7 +19454,7 @@ snapshots: hasown: 2.0.4 math-intrinsics: 1.1.0 - get-port-please@3.2.0(patch_hash=f43f9c8f88f0cd1126a5c319226c9c345e89215b8f51f87c8650af96bc87953a): {} + get-port-please@3.2.0: {} get-proto@1.0.1: dependencies: @@ -20087,7 +20086,7 @@ snapshots: consola: 3.4.2 crossws: 0.4.15(srvx@1.0.5) defu: 6.1.7 - get-port-please: 3.2.0(patch_hash=f43f9c8f88f0cd1126a5c319226c9c345e89215b8f51f87c8650af96bc87953a) + get-port-please: 3.2.0 h3: 1.15.11(srvx@1.0.5) http-shutdown: 1.2.2 jiti: 2.7.0 @@ -20108,7 +20107,7 @@ snapshots: consola: 3.4.2 crossws: 0.4.15(srvx@1.0.6) defu: 6.1.7 - get-port-please: 3.2.0(patch_hash=f43f9c8f88f0cd1126a5c319226c9c345e89215b8f51f87c8650af96bc87953a) + get-port-please: 3.2.0 h3: 1.15.11(srvx@1.0.6) http-shutdown: 1.2.2 jiti: 2.7.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d940df8b6..9f7ea65b5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -103,7 +103,6 @@ overrides: patchedDependencies: comark-docs@0.0.1: patches/comark-docs@0.0.1.patch - get-port-please@3.2.0: patches/get-port-please@3.2.0.patch allowBuilds: '@parcel/watcher': false diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts index b62676686..a014b5475 100644 --- a/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts @@ -5,6 +5,7 @@ export interface CreateDevServerOptions { host?: string; port?: number; + defaultPort?: number; flags?: Record; distDir?: StaticAssetsSource; basePath?: string;