From 34fd2ed0fbf411178e61cb06cb667e7a2f1c3fa9 Mon Sep 17 00:00:00 2001 From: pdelfan Date: Wed, 14 Oct 2026 15:07:19 -0700 Subject: [PATCH] When the app runs on this machine and its port is taken, the server moves to the next free one, trying up to twenty, and PUBLIC_URL with it, since the OAuth redirect and the app's links name that address, and logs a warning saying so. It moves only when PUBLIC_URL is loopback on that same port, so a deployment, or a loopback URL that names another port, keeps its port and fails to start as before, since a reverse proxy points at it. A port counts as taken when it is at every address or at 127.0.0.1 or ::1 alone, since macOS lets a listener on every address share a port with one on 127.0.0.1, which then still gets the requests for http://127.0.0.1:3000. The tunnel picks the free port itself and points both ssh and the server at it. The foundation spec and .env.example describe it, and the tests cover the probe and when the environment moves. --- .env.example | 1 + apps/server/src/index.ts | 6 +++- apps/server/src/port.ts | 58 ++++++++++++++++++++++++++++++++ apps/server/src/tunnel-cli.ts | 7 ++-- apps/server/tests/port.test.ts | 60 ++++++++++++++++++++++++++++++++++ specs/foundation.md | 2 +- 6 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 apps/server/src/port.ts create mode 100644 apps/server/tests/port.test.ts diff --git a/.env.example b/.env.example index acb9d2b..43747b8 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,7 @@ ADMIN_DIDS= # The URL people reach the app at. HTTPS in production. PUBLIC_URL=http://127.0.0.1:3000 +# Locally, a taken port moves the server and PUBLIC_URL to the next free one. PORT=3000 DATA_DIR=./data LOG_LEVEL=info diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 2aa4975..89fc2db 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -6,14 +6,18 @@ import { loadConfig } from './config.ts' import { createDb } from './db/index.ts' import { createLogger } from './logger.ts' import { findInstalledPlugins } from './plugins/installed.ts' +import { withFreePort } from './port.ts' import { createServer } from './server.ts' import { loadBuiltWeb, loadDevWeb } from './web.ts' if (existsSync('.env')) process.loadEnvFile('.env') else if (existsSync('../../.env')) process.loadEnvFile('../../.env') -const config = loadConfig(process.env) +const env = await withFreePort(process.env) +const config = loadConfig(env) const logger = createLogger(config.logLevel) +if (env !== process.env) + logger.warn(`port ${process.env.PORT || 3000} is in use, so the server moved to ${config.port}`) process.on('unhandledRejection', (err) => logger.error({ err }, 'unhandled promise rejection')) const db = createDb(config.databaseUrl) const installed = await findInstalledPlugins( diff --git a/apps/server/src/port.ts b/apps/server/src/port.ts new file mode 100644 index 0000000..56f20e2 --- /dev/null +++ b/apps/server/src/port.ts @@ -0,0 +1,58 @@ +import { createServer } from 'node:net' +import { isLoopbackUrl } from './auth/oauth-client.ts' + +const DEFAULT_PORT = 3000 +const TRIES = 20 + +/** Whether something already listens on the port at the host, or at every address without one. */ +function isBusyAt(port: number, host?: string): Promise { + return new Promise((resolve) => { + const probe = createServer() + // Any other error, such as a host without IPv6, leaves the port to http.listen to judge. + probe.once('error', (err: NodeJS.ErrnoException) => resolve(err.code === 'EADDRINUSE')) + probe.listen(port, host, () => probe.close(() => resolve(false))) + }) +} + +/** + * Whether the port is taken. The loopback addresses are checked on their own, since macOS lets + * a listener on every address share a port with one on 127.0.0.1, which then gets its requests. + */ +async function isBusy(port: number): Promise { + for (const host of [undefined, '127.0.0.1', '::1']) { + if (await isBusyAt(port, host)) return true + } + return false +} + +/** The first port from the given one up that nothing listens on. */ +export async function findFreePort(port: number): Promise { + for (let candidate = port; candidate < port + TRIES && candidate <= 65535; candidate++) { + if (!(await isBusy(candidate))) return candidate + } + throw new Error(`Ports ${port} to ${port + TRIES - 1} are all in use.`) +} + +function urlPort(url: URL): number { + if (url.port) return Number(url.port) + return url.protocol === 'https:' ? 443 : 80 +} + +/** + * When the app runs on this machine, move it to the next free port if its own is taken, and + * PUBLIC_URL with it. A deployment, whose PUBLIC_URL is not loopback, keeps its port and fails + * to start instead, since a reverse proxy points at that port. + */ +export async function withFreePort( + env: Record, +): Promise> { + const port = Number(env.PORT || DEFAULT_PORT) + // An invalid value is left for loadConfig to report. + const publicUrl = URL.parse(env.PUBLIC_URL || `http://127.0.0.1:${DEFAULT_PORT}`) + if (!publicUrl || !Number.isInteger(port) || port < 1 || port > 65535) return env + if (!isLoopbackUrl(publicUrl.href) || urlPort(publicUrl) !== port) return env + const free = await findFreePort(port) + if (free === port) return env + publicUrl.port = String(free) + return { ...env, PORT: String(free), PUBLIC_URL: publicUrl.href.replace(/\/$/, '') } +} diff --git a/apps/server/src/tunnel-cli.ts b/apps/server/src/tunnel-cli.ts index 93ae3af..b3b6364 100644 --- a/apps/server/src/tunnel-cli.ts +++ b/apps/server/src/tunnel-cli.ts @@ -1,5 +1,6 @@ import { type ChildProcess, spawn } from 'node:child_process' import { existsSync } from 'node:fs' +import { findFreePort } from './port.ts' // Runs the dev server behind a localhost.run tunnel, so a phone or another device can sign in over // HTTPS. It needs only ssh and no account. The tunnel URL becomes PUBLIC_URL for this run only, @@ -17,7 +18,8 @@ if (!process.env.OAUTH_PRIVATE_KEYS) { const start = process.argv.includes('--start') const args = process.argv.slice(2).filter((arg) => arg !== '--start') -const port = process.env.PORT || '3000' +// The tunnel forwards to the next free port when this one is taken, and the server listens there. +const port = String(await findFreePort(Number(process.env.PORT || 3000))) const children: ChildProcess[] = [] /** Stop every child, then exit with the given code. */ @@ -85,9 +87,10 @@ const server = spawn( { stdio: 'inherit', env: start - ? { ...process.env, NODE_ENV: 'production', PUBLIC_URL: publicUrl } + ? { ...process.env, NODE_ENV: 'production', PORT: port, PUBLIC_URL: publicUrl } : { ...process.env, + PORT: port, PUBLIC_URL: publicUrl, // Vite's dev server rejects requests for hosts it doesn't know. __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS: new URL(publicUrl).hostname, diff --git a/apps/server/tests/port.test.ts b/apps/server/tests/port.test.ts new file mode 100644 index 0000000..25c3f0a --- /dev/null +++ b/apps/server/tests/port.test.ts @@ -0,0 +1,60 @@ +import { createServer, type Server } from 'node:net' +import { afterEach, describe, expect, it } from 'vitest' +import { findFreePort, withFreePort } from '../src/port.ts' + +const servers: Server[] = [] + +/** Listen on a free port, at every address or only the host, and hold it until the test ends. */ +async function occupy(host?: string): Promise { + const server = createServer() + servers.push(server) + await new Promise((resolve) => server.listen(0, host, resolve)) + return (server.address() as { port: number }).port +} + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => new Promise((done) => server.close(done)))) +}) + +describe('findFreePort', () => { + it('keeps a free port and skips a busy one', async () => { + const busy = await occupy() + expect(await findFreePort(busy)).toBeGreaterThan(busy) + const free = await findFreePort(busy + 1) + expect(await findFreePort(free)).toBe(free) + }) + + it('skips a port taken only on 127.0.0.1', async () => { + const busy = await occupy('127.0.0.1') + expect(await findFreePort(busy)).toBeGreaterThan(busy) + }) +}) + +describe('withFreePort', () => { + it('moves a busy loopback port and PUBLIC_URL with it', async () => { + const busy = await occupy() + const env = await withFreePort({ PORT: String(busy), PUBLIC_URL: `http://127.0.0.1:${busy}` }) + const port = Number(env.PORT) + expect(port).toBeGreaterThan(busy) + expect(env.PUBLIC_URL).toBe(`http://127.0.0.1:${port}`) + }) + + it('leaves the environment as it is when the port is free', async () => { + const free = await findFreePort((await occupy()) + 1) + const env = { PORT: String(free), PUBLIC_URL: `http://localhost:${free}` } + expect(await withFreePort(env)).toBe(env) + }) + + it('keeps a deployment on its port, and a PUBLIC_URL that names another port', async () => { + const busy = await occupy() + const deployed = { PORT: String(busy), PUBLIC_URL: 'https://chat.example.com' } + expect(await withFreePort(deployed)).toBe(deployed) + const proxied = { PORT: String(busy), PUBLIC_URL: 'http://127.0.0.1:8080' } + expect(await withFreePort(proxied)).toBe(proxied) + }) + + it('leaves invalid values for loadConfig to report', async () => { + const env = { PORT: '0', PUBLIC_URL: 'nope' } + expect(await withFreePort(env)).toBe(env) + }) +}) diff --git a/specs/foundation.md b/specs/foundation.md index f8431ad..0148c35 100644 --- a/specs/foundation.md +++ b/specs/foundation.md @@ -57,7 +57,7 @@ Settings live in one of two places, never both: | `NODE_ENV` | `development` | `development`, `test`, or `production`. | | `DATABASE_URL` | `sqlite:./data/scn-chat.sqlite` | `sqlite:` or `postgres://...`. | | `PUBLIC_URL` | `http://127.0.0.1:3000` | The URL people reach the app at. | -| `PORT` | `3000` | | +| `PORT` | `3000` | When `PUBLIC_URL` is loopback on this port and the port is taken, the server moves to the next free one and `PUBLIC_URL` with it, and logs a warning. A deployment keeps its port. | | `DATA_DIR` | `./data` | | | `LOG_LEVEL` | `info` | | -- knotmirror 0.0.1