FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

feat(cli): shift off a busy port + openable banner URL on jss start — closes #557 by melvincarvalho · Pull Request #574 · JavaScriptSolidServer/JavaScriptSolidServer · GitHub

Repository navigation

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension .js  (4) All 1 file type selected
Viewed files
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Unified
Split
Hide whitespace
Diff view
Unified
Split
Hide whitespace
24 changes: 22 additions & 2 deletions bin/jss.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { createInvite, listInvites, revokeInvite } from '../src/idp/invites.js';
import { findByUsername, updatePassword, deleteAccount } from '../src/idp/accounts.js';
import { setQuotaLimit, getQuotaInfo, reconcileQuota, formatBytes } from '../src/storage/quota.js';
import { parseSize } from '../src/config.js';
import { findFreePort, formatUrl } from '../src/utils/port.js';
import crypto from 'crypto';
import fs from 'fs-extra';
import path from 'path';
Expand Down Expand Up @@ -176,10 +177,29 @@ program
process.exit(0);
}

// If the requested port is busy, shift up to the next free one
// (Vite-style), rather than dying on a raw EADDRINUSE — a common
// first-run papercut when a stale instance is still running (#557).
// Must run BEFORE the issuer/baseUrl are derived so they reflect
// the port we actually bind. The notice goes to stderr so it
// surfaces even under --quiet (a port change the operator didn't
// ask for is operationally significant).
const requestedPort = config.port;
const boundPort = await findFreePort(requestedPort, config.host);
if (boundPort === null) {
console.error(
`Error: no free port found in ${requestedPort}–${requestedPort + 9} on ${config.host}.`
);
process.exit(1);
}
if (boundPort !== requestedPort) {
console.error(` Port ${requestedPort} is in use — using ${boundPort} instead.`);
config.port = boundPort;
}

// Determine IdP issuer URL
const protocol = config.ssl ? 'https' : 'http';
const serverHost = config.host === '0.0.0.0' ? 'localhost' : config.host;
const baseUrl = `${protocol}://${serverHost}:${config.port}`;
const baseUrl = formatUrl(config.host, config.port, protocol);
// Ensure issuer has trailing slash for CTH compatibility
let idpIssuer = config.idpIssuer || baseUrl;
if (idpIssuer && !idpIssuer.endsWith('/')) {
Expand Down
66 changes: 66 additions & 0 deletions src/utils/port.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Port + URL helpers for `jss start` (#557).
*
* Ported from jspod's lib/start.js, where both have been proven in
* production. Kept in a standalone module (not inline in bin/jss.js) so
* they're unit-testable without executing the CLI.
*/

import { createServer } from 'net';

/**
* Format a host + port into an URL a human can actually open, for the
* startup banner. Wildcard bind addresses (0.0.0.0, ::, *) aren't
* connectable, so show `localhost`; a bare IPv6 literal gets bracketed.
*
* @param {string} host - the bind host
* @param {number} port - the bound port
* @param {string} [protocol] - 'http' (default) or 'https'
* @returns {string}
*/
export function formatUrl(host, port, protocol = 'http') {
if (host === '0.0.0.0' || host === '::' || host === '*') {
return `${protocol}://localhost:${port}`;
}
if (host.includes(':')) {
// IPv6 literal — must be bracketed in a URL authority.
return `${protocol}://[${host}]:${port}`;
}
return `${protocol}://${host}:${port}`;
}

/**
* Find a free port at or above `startPort` on `host`. Mirrors Vite's
* behaviour: probe one port at a time, up to `maxTries`, returning the
* first bindable one — or `null` if every port in the range is taken.
*
* Only EADDRINUSE counts as "busy" (try the next port). Any other
* bind failure — EACCES on a privileged port, EADDRNOTAVAIL for an
* invalid host — is a real error and is re-thrown, so the caller
* surfaces the actual cause instead of a misleading "no free port".
*
* Uses a throwaway net server to test bindability without committing the
* real server. (There is an inherent TOCTOU window between this probe
* and the real listen; the caller falls back to its normal listen-error
* path if the chosen port is grabbed in between.)
*
* @param {number} startPort
* @param {string} host
* @param {number} [maxTries]
* @returns {Promise<number|null>}
*/
export async function findFreePort(startPort, host, maxTries = 10) {
for (let p = startPort; p < startPort + maxTries; p++) {
const free = await new Promise((resolve, reject) => {
const srv = createServer();
srv.once('error', (err) => {
if (err.code === 'EADDRINUSE') resolve(false); // busy — try the next port
else reject(err); // real failure — surface it
});
srv.once('listening', () => srv.close(() => resolve(true)));
srv.listen(p, host);
});
if (free) return p;
}
return null;
}
114 changes: 114 additions & 0 deletions test/port-shift-cli.test.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* CLI wiring for the busy-port shift (#557).
*
* test/port.test.js covers findFreePort/formatUrl directly; this spawns
* `bin/jss.js start` on an occupied port and asserts the real user-facing
* behaviour: instead of dying on EADDRINUSE, jss shifts to the next free
* port (Vite-style), prints a notice to stderr (so it surfaces even under
* --quiet), and actually serves there.
*/

import { describe, it, afterEach } from 'node:test';
import assert from 'node:assert';
import { spawn } from 'node:child_process';
import { createServer as createNetServer } from 'node:net';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import fs from 'fs-extra';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BIN = path.join(__dirname, '..', 'bin', 'jss.js');
const TEST_DATA_DIR = './test-data-port-shift-cli';
const HOST = '127.0.0.1';

let child;
let blocker;

function freePort() {
return new Promise((resolve, reject) => {
const srv = createNetServer();
srv.once('error', reject);
srv.listen(0, HOST, () => {
const { port } = srv.address();
srv.close(() => resolve(port));
});
});
}

function occupy(port) {
return new Promise((resolve, reject) => {
const srv = createNetServer();
srv.once('error', reject);
srv.listen(port, HOST, () => resolve(srv));
});
}

async function stopCli() {
if (!child) return;
const c = child;
child = null;
if (c.exitCode !== null || c.signalCode !== null) return;
const gone = new Promise((r) => c.once('exit', r));
c.kill('SIGTERM');
await Promise.race([gone, new Promise((r) => setTimeout(r, 3000))]);
if (c.exitCode === null && c.signalCode === null) c.kill('SIGKILL');
await Promise.race([gone, new Promise((r) => setTimeout(r, 2000))]);
}

describe('bin/jss.js — busy-port shift (#557)', () => {
afterEach(async () => {
await stopCli();
if (blocker) {
await new Promise((r) => blocker.close(r));
blocker = null;
}
await fs.remove(TEST_DATA_DIR);
});

it('shifts to the next free port and serves there when the requested port is busy', async () => {
await fs.emptyDir(TEST_DATA_DIR);
const busy = await freePort();
blocker = await occupy(busy); // hold `busy` so jss can't bind it

let stderr = '';
child = spawn(process.execPath, [
BIN, 'start',
'--port', String(busy),
'--host', HOST,
'--root', TEST_DATA_DIR,
'--quiet', // banner suppressed; the shift notice still goes to stderr
], { env: { ...process.env }, stdio: ['ignore', 'pipe', 'pipe'] });
child.stderr.on('data', (d) => { stderr += d; });

const deadline = Date.now() + 15_000;
let exited = false;
child.once('exit', () => { exited = true; });

// 1. The shift notice (which carries the chosen port) must appear.
let shifted = null;
while (Date.now() < deadline) {
const m = stderr.match(/using (\d+) instead/);
if (m) { shifted = Number(m[1]); break; }
if (exited) throw new Error(`jss exited before shifting. stderr: ${stderr}`);
await new Promise((r) => setTimeout(r, 100));
}
assert.ok(shifted, `expected a port-shift notice on stderr; got: ${stderr || '(empty)'}`);
assert.ok(stderr.includes(`Port ${busy} is in use`), 'notice should name the busy port');
assert.notStrictEqual(shifted, busy);

// 2. The server must actually serve on the shifted port.
const url = `http://${HOST}:${shifted}`;
let ready = false;
while (Date.now() < deadline) {
if (exited) throw new Error(`jss exited before serving. stderr: ${stderr}`);
try {
await fetch(url, { signal: AbortSignal.timeout(1000) });
ready = true;
break;
} catch {
await new Promise((r) => setTimeout(r, 200));
}
}
assert.ok(ready, `jss should serve on the shifted port ${shifted}`);
});
});
98 changes: 98 additions & 0 deletions test/port.test.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* Port + URL helpers for `jss start` (#557): findFreePort shifts off a
* busy port (Vite-style); formatUrl turns a bind host into an openable
* banner URL.
*/

import { describe, it } from 'node:test';
import assert from 'node:assert';
import { createServer } from 'net';
import { findFreePort, formatUrl } from '../src/utils/port.js';

const HOST = '127.0.0.1';

function listen(port, host) {
return new Promise((resolve, reject) => {
const srv = createServer();
srv.once('error', reject);
srv.listen(port, host, () => resolve(srv));
});
}
function close(srv) {
return new Promise((resolve) => srv.close(resolve));
}
function freePort(host) {
return new Promise((resolve, reject) => {
const srv = createServer();
srv.once('error', reject);
srv.listen(0, host, () => {
const { port } = srv.address();
srv.close(() => resolve(port));
});
});
}

describe('formatUrl (#557)', () => {
it('rewrites wildcard bind addresses to localhost', () => {
assert.strictEqual(formatUrl('0.0.0.0', 4443), 'http://localhost:4443');
assert.strictEqual(formatUrl('::', 4443), 'http://localhost:4443');
assert.strictEqual(formatUrl('*', 4443), 'http://localhost:4443');
});

it('passes a normal host through unchanged', () => {
assert.strictEqual(formatUrl('example.com', 8080), 'http://example.com:8080');
assert.strictEqual(formatUrl('127.0.0.1', 3000), 'http://127.0.0.1:3000');
});

it('brackets an IPv6 literal', () => {
assert.strictEqual(formatUrl('::1', 4443), 'http://[::1]:4443');
assert.strictEqual(formatUrl('fe80::1', 4443), 'http://[fe80::1]:4443');
});

it('honours the protocol argument', () => {
assert.strictEqual(formatUrl('example.com', 443, 'https'), 'https://example.com:443');
assert.strictEqual(formatUrl('0.0.0.0', 4443, 'https'), 'https://localhost:4443');
});
});

describe('findFreePort (#557)', () => {
it('returns the requested port when it is free', async () => {
const p = await freePort(HOST);
assert.strictEqual(await findFreePort(p, HOST), p);
});

it('shifts to the next free port when the requested one is busy', async () => {
const p = await freePort(HOST);
const blocker = await listen(p, HOST);
try {
const got = await findFreePort(p, HOST);
assert.ok(got > p, `expected a port above ${p}, got ${got}`);
assert.ok(got < p + 10, 'should stay within the probe window');
} finally {
await close(blocker);
}
});

it('returns null when every port in the window is taken', async () => {
const p = await freePort(HOST);
const a = await listen(p, HOST);
const b = await listen(p + 1, HOST);
try {
// window of 2 — both taken → no free port
assert.strictEqual(await findFreePort(p, HOST, 2), null);
} finally {
await close(a);
await close(b);
}
});

it('re-throws a non-EADDRINUSE bind error instead of reporting "no free port"', async () => {
// 192.0.2.0/24 (TEST-NET-1) isn't a local interface → EADDRNOTAVAIL,
// which is a real failure, not a busy port. Must propagate so the CLI
// surfaces the actual cause rather than "no free port found".
await assert.rejects(
() => findFreePort(40000, '192.0.2.1', 1),
(err) => err && err.code !== undefined && err.code !== 'EADDRINUSE',
);
});
});

Back | FazBrowse Home | New Git URL