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