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` | |