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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 12 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,11 @@ In development (when the runtime reports `dev`), loopback origins — `localhost

The module publishes three [config variables](https://antelopejs.com/docs/concepts/configuration#module-config-variables) other modules reference from their own configuration with `${@api.<VAR_NAME>}`:

| Variable | Type | Description |
| --------------------- | ------ | --------------------------------------------------------------------------- |
| `API_PORT` | number | The port the server reserved during `provide`, and the port it later binds. |
| `API_LOCAL_BASE_URL` | string | The same-host origin, always on loopback: `http://127.0.0.1:<API_PORT>`. |
| `API_PUBLIC_BASE_URL` | string | The origin external clients must use, from the `publicBaseUrl` key. |
| Variable | Type | Description |
| --------------------- | ------ | ------------------------------------------------------------------------ |
| `API_PORT` | number | The port the server bound during `provide`, and the port it serves. |
| `API_LOCAL_BASE_URL` | string | The same-host origin, always on loopback: `http://127.0.0.1:<API_PORT>`. |
| `API_PUBLIC_BASE_URL` | string | The origin external clients must use, from the `publicBaseUrl` key. |

All three derive from the **first entry of `servers[]`**, the same entry the dev registry and the frontend discovery already treat as the project's canonical api endpoint. Additional servers are still started, but they are not advertised through config variables.

Expand All @@ -116,13 +116,15 @@ export default defineConfig({

The variables are published from the `provide` callback, which the core runs before any module constructs.

To publish a port it can guarantee, the module reserves it right there: it binds a throwaway socket on the configured port, holds it while every other module constructs, and releases it immediately before the real `listen()` in `start`. The value other modules receive is therefore the port the server actually binds — never a stale one.
To publish a port it can guarantee, the module binds the listening socket right there, once, and publishes the port read back from it. The socket stays open while every other module constructs, and `start` serves the HTTP or HTTPS server from it — the server never binds a port of its own. The value other modules receive is therefore the port the server actually serves — never a stale one.

The reservation honours the existing port rules:
Connections that arrive before `start` wait unread in the socket's queue and are served, like every later connection, once the server starts.

- `strictPort: true`, or any non-development runtime, reserves exactly the requested port or fails the boot with a `PortReservationError` naming the port.
- In development, the reservation falls back to the next free port (up to 20 above the requested one, then an OS-assigned port), exactly as `listen()` did before.
- `port: 0` reserves an OS-assigned port and publishes it.
Binding honours the existing port rules:

- `strictPort: true`, or any non-development runtime, binds exactly the requested port or fails the boot with a `PortReservationError` naming the port.
- In development, binding falls back to the next free port (up to 20 above the requested one, then an OS-assigned port).
- `port: 0` binds an OS-assigned port and publishes it.

### One advertised origin

Expand Down
106 changes: 67 additions & 39 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,19 @@ import type { ConfigVars } from "@antelopejs/interface-core/config";
import type { DevServerEndpoint } from "@antelopejs/interface-core/runtime";

import { resolveDevMode } from "./dev-mode";
import { listenServer } from "./port-binding";
import { logServerStarted } from "./port-binding";
import type { Config } from "./server-config";
import { buildConfigVars } from "./config-vars";
import { createConfiguredServer } from "./server-factory";
import { configure, getConfig, setCorsConfig } from "./module-config";

export { configure, getConfig, setCorsConfig };
import {
releaseReservedPorts,
type ReservedPort,
reserveServerPorts,
} from "./port-reservation";
type BoundListener,
bindServerPorts,
closeListeners,
isBoundFor,
} from "./port-listener";
import {
collectListeningEndpoints,
registerDevServerEndpoints,
Expand All @@ -26,52 +27,52 @@ import "./middlewares/cors";

let servers: net.Server[] = [];
let listening = false;
let reservations: ReservedPort[] = [];
let listeners: BoundListener[] = [];

function releaseReservations(): Promise<void> {
const pending = reservations;
reservations = [];
return releaseReservedPorts(pending);
function releaseListeners(): Promise<void> {
const pending = listeners;
listeners = [];
return closeListeners(pending);
}

async function reserveConfiguredPorts(): Promise<void> {
await releaseReservations();
async function bindConfiguredPorts(): Promise<void> {
await releaseListeners();

const config = getConfig();
reservations = await reserveServerPorts(
listeners = await bindServerPorts(
config.servers ?? [],
await shouldAllowPortFallback(config),
);
}

/**
* Copies the reserved ports onto the current configuration. Exposed for
* Copies the bound ports onto the current configuration. Exposed for
* tests, which reproduce the rebuilt configuration the core may hand to
* `construct`.
*
* `provide` and `construct` receive the configuration through separate
* substitution passes, so the object `construct` sees may be a rebuilt
* copy carrying the originally requested ports again. Re-applying the
* reservation is what keeps the published `API_PORT` and the port
* `start` binds identical, whatever the core hands over.
* bound ports is what keeps the published `API_PORT` and the port the
* module reports and logs identical, whatever the core hands over.
*/
export function applyReservedPorts(): void {
const servers = getConfig().servers ?? [];
reservations.forEach((reservation, index) => {
listeners.forEach((listener, index) => {
const serverConfig = servers[index];
if (serverConfig) {
serverConfig.port = reservation.port;
serverConfig.port = listener.port;
}
});
}

async function publishConfigVars(): Promise<ConfigVars> {
await reserveConfiguredPorts();
await bindConfiguredPorts();

try {
return buildConfigVars(getConfig());
} catch (error) {
await releaseReservations();
await releaseListeners();
throw error;
}
}
Expand All @@ -81,8 +82,8 @@ async function publishConfigVars(): Promise<ConfigVars> {
*
* Nothing has constructed at this point, so this path awaits no other
* module's interface: it only reads the runtime information the core
* registers before the module lifecycle starts, and holds a socket on
* the port the server will bind.
* registers before the module lifecycle starts, and binds the listening
* socket the server is served from once it starts.
*/
export async function provide(config: Config): Promise<ConfigVars> {
configure(config);
Expand Down Expand Up @@ -116,26 +117,59 @@ function closeServers(): Promise<void> {
return Promise.all(closing).then(() => undefined);
}

/**
* Creates the configured servers and, unless `autoListen` is `false`,
* serves them from the listening sockets. Calling it again replaces the
* servers: the sockets stay bound and hand their connections to the new
* ones.
*/
export function start(): void {
const serversClosed = closeServers();
void closeServers();
servers = (getConfig().servers ?? []).map((serverConfig) =>
createConfiguredServer(serverConfig),
);

if (getConfig().autoListen !== false) {
void serversClosed
.then(() => listenServers())
.catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
Logging.Error(`Unable to start listening servers: ${message}`);
});
listenServers().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
Logging.Error(`Unable to start listening servers: ${message}`);
});
}
}

export function getListeningEndpoints(): DevServerEndpoint[] {
return collectListeningEndpoints(servers, getConfig().servers);
if (!listening) {
return [];
}

return collectListeningEndpoints(
listeners.map((listener) => listener.socket),
getConfig().servers,
);
}

async function ensureListeners(): Promise<void> {
if (isBoundFor(listeners, getConfig().servers ?? [])) {
return;
}

await bindConfiguredPorts();
}

function serveListeners(): void {
const configs = getConfig().servers ?? [];
listeners.forEach((listener, index) => {
listener.serve(servers[index]);
logServerStarted(configs[index], listener.requestedPort, listener.port);
});
}

/**
* Serves the created servers from their listening sockets. The sockets
* bound during `provide` are kept when the configuration still names
* their address; otherwise — after a `stop`, when `provide` never ran, or
* when the configuration changed — they are bound again.
*/
export async function listenServers(): Promise<void> {
if (listening || servers.length === 0) {
return;
Expand All @@ -144,22 +178,16 @@ export async function listenServers(): Promise<void> {
listening = true;

try {
const allowPortFallback = await shouldAllowPortFallback(getConfig());
await releaseReservations();
await Promise.all(
(getConfig().servers ?? []).map((serverConfig, index) =>
listenServer(servers[index], serverConfig, allowPortFallback),
),
);
await ensureListeners();
} catch (error) {
listening = false;
throw error;
}

serveListeners();
await registerDevServerEndpoints(getListeningEndpoints());
}

export async function stop(): Promise<void> {
await releaseReservations();
await closeServers();
await Promise.all([closeServers(), releaseListeners()]);
}
78 changes: 4 additions & 74 deletions src/port-binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,33 +10,6 @@ import {

const MAX_PORT_FALLBACK_OFFSET = 20;

function listenOnce(
server: net.Server,
port: number,
host?: string,
): Promise<void> {
return new Promise<void>((resolve, reject) => {
const onListening = () => {
cleanup();
resolve();
};

const onError = (error: Error) => {
cleanup();
reject(error);
};

const cleanup = () => {
server.off("listening", onListening);
server.off("error", onError);
};

server.once("listening", onListening);
server.once("error", onError);
server.listen(port, host);
});
}

export function isPortInUseError(error: unknown): boolean {
return (error as NodeJS.ErrnoException)?.code === "EADDRINUSE";
}
Expand Down Expand Up @@ -81,33 +54,10 @@ export function buildCandidatePorts(
return [...sequentialPorts, RANDOM_PORT];
}

async function listenServerWithFallback(
server: net.Server,
config: ServerConfig,
requestedPort: number,
allowPortFallback: boolean,
): Promise<number> {
const candidatePorts = buildCandidatePorts(requestedPort, allowPortFallback);
let portInUseError: unknown = new Error(
`Unable to bind ${config.protocol} server on port ${requestedPort}`,
);

for (const candidatePort of candidatePorts) {
try {
await listenOnce(server, candidatePort, config.host);
return resolveBoundPort(server, candidatePort);
} catch (error) {
if (!isPortInUseError(error)) {
throw error;
}
portInUseError = error;
}
}

throw portInUseError;
}

function logServerStarted(
/**
* Logs where a server listens, naming the fallback when it moved.
*/
export function logServerStarted(
config: ServerConfig,
requestedPort: number,
boundPort: number,
Expand All @@ -122,23 +72,3 @@ function logServerStarted(
`Port ${requestedPort} in use, listening on ${serverUrl} instead`,
);
}

export async function listenServer(
server: net.Server,
config: ServerConfig,
allowPortFallback: boolean,
): Promise<void> {
if (server.listening) {
return;
}

const requestedPort = resolveRequestedPort(config);
const boundPort = await listenServerWithFallback(
server,
config,
requestedPort,
allowPortFallback,
);
config.port = boundPort;
logServerStarted(config, requestedPort, boundPort);
}
Loading
Loading