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
1 change: 1 addition & 0 deletions packages/appkit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
"@types/semver": "7.7.1",
"dotenv": "16.6.1",
"express": "4.22.0",
"get-port": "7.2.0",
"obug": "2.1.1",
"pg": "8.18.0",
"picocolors": "1.1.1",
Expand Down
41 changes: 39 additions & 2 deletions packages/appkit/src/plugins/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Server as HTTPServer } from "node:http";
import path from "node:path";
import dotenv from "dotenv";
import express from "express";
import getPort, { portNumbers } from "get-port";
import type { PluginClientConfigs, PluginPhase } from "shared";
import { ServerError } from "../../errors";
import { createLogger } from "../../logging/logger";
Expand All @@ -21,6 +22,9 @@ dotenv.config({ path: path.resolve(process.cwd(), "./.env") });

const logger = createLogger("server");

/** Dev-only: try `requested` then consecutive ports (see `get-port` `portNumbers`). */
const devListenPortSpan = 100;

/**
* Server plugin for the AppKit.
*
Expand Down Expand Up @@ -54,6 +58,8 @@ export class ServerPlugin extends Plugin {
private server: HTTPServer | null;
private viteDevServer?: ViteDevServer;
private remoteTunnelController?: RemoteTunnelController;
/** Bound listen port after optional dev-time resolution. */
private resolvedListenPort?: number;
protected declare config: ServerConfig;
private serverExtensions: ((app: express.Application) => void)[] = [];
private rawBodyPaths: Set<string> = new Set();
Expand Down Expand Up @@ -125,8 +131,10 @@ export class ServerPlugin extends Plugin {

await this.setupFrontend(endpoints, pluginConfigs);

const listenPort = await this.resolveListenPort();

const server = this.serverApplication.listen(
this.config.port ?? ServerPlugin.DEFAULT_CONFIG.port,
listenPort,
this.config.host ?? ServerPlugin.DEFAULT_CONFIG.host,
() => this.logStartupInfo(),
);
Expand Down Expand Up @@ -306,10 +314,39 @@ export class ServerPlugin extends Plugin {
return undefined;
}

/**
* In development, prefers {@link ServerConfig.port} / env / default (8000), then
* scans upward using `get-port`'s `portNumbers()` on the listen host until one binds.
* In non-development, uses config / env / default only (no fallback).
*/
private async resolveListenPort(): Promise<number> {
const requested = this.config.port ?? ServerPlugin.DEFAULT_CONFIG.port;

if (process.env.NODE_ENV !== "development") {
this.resolvedListenPort = requested;
return requested;
}

const host = this.config.host ?? ServerPlugin.DEFAULT_CONFIG.host;
const upper = Math.min(requested + devListenPortSpan - 1, 65_535);
const port = await getPort({
host,
port: portNumbers(requested, upper),
});
this.resolvedListenPort = port;
if (port !== requested) {
logger.info("Port %d was busy, picking %d", requested, port);
}
return port;
Comment thread
MarioCadenas marked this conversation as resolved.
}

private logStartupInfo() {
const isDev = process.env.NODE_ENV === "development";
const hasExplicitStaticPath = this.config.staticPath !== undefined;
const port = this.config.port ?? ServerPlugin.DEFAULT_CONFIG.port;
const port =
this.resolvedListenPort ??
this.config.port ??
ServerPlugin.DEFAULT_CONFIG.port;
const host = this.config.host ?? ServerPlugin.DEFAULT_CONFIG.host;

logger.info("Server running on http://%s:%d", host, port);
Expand Down
113 changes: 113 additions & 0 deletions packages/appkit/src/plugins/server/tests/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const {
mockExpressApp,
mockRemoteTunnelControllerMiddleware,
mockRemoteTunnelControllerInstance,
mockGetPort,
} = vi.hoisted(() => {
const httpServer = {
close: vi.fn((cb: any) => cb?.()),
Expand Down Expand Up @@ -36,11 +37,29 @@ const {
isActive: vi.fn().mockReturnValue(false),
};

const mockGetPort = vi.fn(
async (opts?: { port?: number | Iterable<number>; host?: string }) => {
if (opts?.port == null) return 8000;
if (typeof opts.port === "number") return opts.port;
for (const p of opts.port) return p;
return 8000;
},
);

return {
mockHttpServer: httpServer,
mockExpressApp: expressApp,
mockRemoteTunnelControllerMiddleware: remoteTunnelControllerMiddleware,
mockRemoteTunnelControllerInstance: remoteTunnelControllerInstance,
mockGetPort,
};
});

vi.mock("get-port", async (importOriginal) => {
const actual = await importOriginal<typeof import("get-port")>();
return {
...actual,
default: mockGetPort,
};
});

Expand Down Expand Up @@ -247,6 +266,100 @@ describe("ServerPlugin", () => {
);
});

test("uses get-port portNumbers in development when default preferred", async () => {
process.env.NODE_ENV = "development";
mockGetPort.mockResolvedValueOnce(8123);
const plugin = new ServerPlugin({});

await plugin.start();

expect(mockGetPort).toHaveBeenCalledWith(
expect.objectContaining({
host: ServerPlugin.DEFAULT_CONFIG.host,
}),
);
const opts = mockGetPort.mock.calls[0][0] as {
port: Iterable<number>;
};
expect([...opts.port].slice(0, 2)).toEqual([
ServerPlugin.DEFAULT_CONFIG.port,
ServerPlugin.DEFAULT_CONFIG.port + 1,
]);
expect(mockExpressApp.listen).toHaveBeenCalledWith(
8123,
expect.any(String),
expect.any(Function),
);
});

test("uses get-port portNumbers in development when explicit port preferred", async () => {
process.env.NODE_ENV = "development";
mockGetPort.mockResolvedValueOnce(9123);
const plugin = new ServerPlugin({ port: 4000 });

await plugin.start();

expect(mockGetPort).toHaveBeenCalledWith(
expect.objectContaining({
host: ServerPlugin.DEFAULT_CONFIG.host,
}),
);
const opts = mockGetPort.mock.calls[0][0] as {
port: Iterable<number>;
};
expect([...opts.port].slice(0, 2)).toEqual([4000, 4001]);
expect(mockExpressApp.listen).toHaveBeenCalledWith(
9123,
expect.any(String),
expect.any(Function),
);
});

test("does not use get-port outside development", async () => {
process.env.NODE_ENV = "production";
mockGetPort.mockClear();
const plugin = new ServerPlugin({ port: 3000 });

await plugin.start();

expect(mockGetPort).not.toHaveBeenCalled();
expect(mockExpressApp.listen).toHaveBeenCalledWith(
3000,
expect.any(String),
expect.any(Function),
);
});

test("logs info when dev preferred port was busy and another was picked", async () => {
process.env.NODE_ENV = "development";
mockLoggerInfo.mockClear();
mockGetPort.mockResolvedValueOnce(8123);
const plugin = new ServerPlugin({});

await plugin.start();

expect(mockLoggerInfo).toHaveBeenCalledWith(
"Port %d was busy, picking %d",
ServerPlugin.DEFAULT_CONFIG.port,
8123,
);
});

test("does not log busy info when dev preferred port was free", async () => {
process.env.NODE_ENV = "development";
mockLoggerInfo.mockClear();
mockGetPort.mockResolvedValueOnce(ServerPlugin.DEFAULT_CONFIG.port);
const plugin = new ServerPlugin({});

await plugin.start();

expect(mockLoggerInfo).not.toHaveBeenCalledWith(
"Port %d was busy, picking %d",
expect.any(Number),
expect.any(Number),
);
});

test("should setup ViteDevServer in development mode", async () => {
process.env.NODE_ENV = "development";
const plugin = new ServerPlugin({});
Expand Down
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading