Skip to content

Commit 12598dc

Browse files
authored
feat(appkit): scan upward from preferred port in development (#349)
1 parent 665c435 commit 12598dc

4 files changed

Lines changed: 162 additions & 2 deletions

File tree

packages/appkit/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
"@types/semver": "7.7.1",
7676
"dotenv": "16.6.1",
7777
"express": "4.22.0",
78+
"get-port": "7.2.0",
7879
"obug": "2.1.1",
7980
"pg": "8.18.0",
8081
"picocolors": "1.1.1",

packages/appkit/src/plugins/server/index.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Server as HTTPServer } from "node:http";
33
import path from "node:path";
44
import dotenv from "dotenv";
55
import express from "express";
6+
import getPort, { portNumbers } from "get-port";
67
import type { PluginClientConfigs, PluginPhase } from "shared";
78
import { ServerError } from "../../errors";
89
import { createLogger } from "../../logging/logger";
@@ -21,6 +22,9 @@ dotenv.config({ path: path.resolve(process.cwd(), "./.env") });
2122

2223
const logger = createLogger("server");
2324

25+
/** Dev-only: try `requested` then consecutive ports (see `get-port` `portNumbers`). */
26+
const devListenPortSpan = 100;
27+
2428
/**
2529
* Server plugin for the AppKit.
2630
*
@@ -54,6 +58,8 @@ export class ServerPlugin extends Plugin {
5458
private server: HTTPServer | null;
5559
private viteDevServer?: ViteDevServer;
5660
private remoteTunnelController?: RemoteTunnelController;
61+
/** Bound listen port after optional dev-time resolution. */
62+
private resolvedListenPort?: number;
5763
protected declare config: ServerConfig;
5864
private serverExtensions: ((app: express.Application) => void)[] = [];
5965
private rawBodyPaths: Set<string> = new Set();
@@ -125,8 +131,10 @@ export class ServerPlugin extends Plugin {
125131

126132
await this.setupFrontend(endpoints, pluginConfigs);
127133

134+
const listenPort = await this.resolveListenPort();
135+
128136
const server = this.serverApplication.listen(
129-
this.config.port ?? ServerPlugin.DEFAULT_CONFIG.port,
137+
listenPort,
130138
this.config.host ?? ServerPlugin.DEFAULT_CONFIG.host,
131139
() => this.logStartupInfo(),
132140
);
@@ -306,10 +314,39 @@ export class ServerPlugin extends Plugin {
306314
return undefined;
307315
}
308316

317+
/**
318+
* In development, prefers {@link ServerConfig.port} / env / default (8000), then
319+
* scans upward using `get-port`'s `portNumbers()` on the listen host until one binds.
320+
* In non-development, uses config / env / default only (no fallback).
321+
*/
322+
private async resolveListenPort(): Promise<number> {
323+
const requested = this.config.port ?? ServerPlugin.DEFAULT_CONFIG.port;
324+
325+
if (process.env.NODE_ENV !== "development") {
326+
this.resolvedListenPort = requested;
327+
return requested;
328+
}
329+
330+
const host = this.config.host ?? ServerPlugin.DEFAULT_CONFIG.host;
331+
const upper = Math.min(requested + devListenPortSpan - 1, 65_535);
332+
const port = await getPort({
333+
host,
334+
port: portNumbers(requested, upper),
335+
});
336+
this.resolvedListenPort = port;
337+
if (port !== requested) {
338+
logger.info("Port %d was busy, picking %d", requested, port);
339+
}
340+
return port;
341+
}
342+
309343
private logStartupInfo() {
310344
const isDev = process.env.NODE_ENV === "development";
311345
const hasExplicitStaticPath = this.config.staticPath !== undefined;
312-
const port = this.config.port ?? ServerPlugin.DEFAULT_CONFIG.port;
346+
const port =
347+
this.resolvedListenPort ??
348+
this.config.port ??
349+
ServerPlugin.DEFAULT_CONFIG.port;
313350
const host = this.config.host ?? ServerPlugin.DEFAULT_CONFIG.host;
314351

315352
logger.info("Server running on http://%s:%d", host, port);

packages/appkit/src/plugins/server/tests/server.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const {
66
mockExpressApp,
77
mockRemoteTunnelControllerMiddleware,
88
mockRemoteTunnelControllerInstance,
9+
mockGetPort,
910
} = vi.hoisted(() => {
1011
const httpServer = {
1112
close: vi.fn((cb: any) => cb?.()),
@@ -36,11 +37,29 @@ const {
3637
isActive: vi.fn().mockReturnValue(false),
3738
};
3839

40+
const mockGetPort = vi.fn(
41+
async (opts?: { port?: number | Iterable<number>; host?: string }) => {
42+
if (opts?.port == null) return 8000;
43+
if (typeof opts.port === "number") return opts.port;
44+
for (const p of opts.port) return p;
45+
return 8000;
46+
},
47+
);
48+
3949
return {
4050
mockHttpServer: httpServer,
4151
mockExpressApp: expressApp,
4252
mockRemoteTunnelControllerMiddleware: remoteTunnelControllerMiddleware,
4353
mockRemoteTunnelControllerInstance: remoteTunnelControllerInstance,
54+
mockGetPort,
55+
};
56+
});
57+
58+
vi.mock("get-port", async (importOriginal) => {
59+
const actual = await importOriginal<typeof import("get-port")>();
60+
return {
61+
...actual,
62+
default: mockGetPort,
4463
};
4564
});
4665

@@ -247,6 +266,100 @@ describe("ServerPlugin", () => {
247266
);
248267
});
249268

269+
test("uses get-port portNumbers in development when default preferred", async () => {
270+
process.env.NODE_ENV = "development";
271+
mockGetPort.mockResolvedValueOnce(8123);
272+
const plugin = new ServerPlugin({});
273+
274+
await plugin.start();
275+
276+
expect(mockGetPort).toHaveBeenCalledWith(
277+
expect.objectContaining({
278+
host: ServerPlugin.DEFAULT_CONFIG.host,
279+
}),
280+
);
281+
const opts = mockGetPort.mock.calls[0][0] as {
282+
port: Iterable<number>;
283+
};
284+
expect([...opts.port].slice(0, 2)).toEqual([
285+
ServerPlugin.DEFAULT_CONFIG.port,
286+
ServerPlugin.DEFAULT_CONFIG.port + 1,
287+
]);
288+
expect(mockExpressApp.listen).toHaveBeenCalledWith(
289+
8123,
290+
expect.any(String),
291+
expect.any(Function),
292+
);
293+
});
294+
295+
test("uses get-port portNumbers in development when explicit port preferred", async () => {
296+
process.env.NODE_ENV = "development";
297+
mockGetPort.mockResolvedValueOnce(9123);
298+
const plugin = new ServerPlugin({ port: 4000 });
299+
300+
await plugin.start();
301+
302+
expect(mockGetPort).toHaveBeenCalledWith(
303+
expect.objectContaining({
304+
host: ServerPlugin.DEFAULT_CONFIG.host,
305+
}),
306+
);
307+
const opts = mockGetPort.mock.calls[0][0] as {
308+
port: Iterable<number>;
309+
};
310+
expect([...opts.port].slice(0, 2)).toEqual([4000, 4001]);
311+
expect(mockExpressApp.listen).toHaveBeenCalledWith(
312+
9123,
313+
expect.any(String),
314+
expect.any(Function),
315+
);
316+
});
317+
318+
test("does not use get-port outside development", async () => {
319+
process.env.NODE_ENV = "production";
320+
mockGetPort.mockClear();
321+
const plugin = new ServerPlugin({ port: 3000 });
322+
323+
await plugin.start();
324+
325+
expect(mockGetPort).not.toHaveBeenCalled();
326+
expect(mockExpressApp.listen).toHaveBeenCalledWith(
327+
3000,
328+
expect.any(String),
329+
expect.any(Function),
330+
);
331+
});
332+
333+
test("logs info when dev preferred port was busy and another was picked", async () => {
334+
process.env.NODE_ENV = "development";
335+
mockLoggerInfo.mockClear();
336+
mockGetPort.mockResolvedValueOnce(8123);
337+
const plugin = new ServerPlugin({});
338+
339+
await plugin.start();
340+
341+
expect(mockLoggerInfo).toHaveBeenCalledWith(
342+
"Port %d was busy, picking %d",
343+
ServerPlugin.DEFAULT_CONFIG.port,
344+
8123,
345+
);
346+
});
347+
348+
test("does not log busy info when dev preferred port was free", async () => {
349+
process.env.NODE_ENV = "development";
350+
mockLoggerInfo.mockClear();
351+
mockGetPort.mockResolvedValueOnce(ServerPlugin.DEFAULT_CONFIG.port);
352+
const plugin = new ServerPlugin({});
353+
354+
await plugin.start();
355+
356+
expect(mockLoggerInfo).not.toHaveBeenCalledWith(
357+
"Port %d was busy, picking %d",
358+
expect.any(Number),
359+
expect.any(Number),
360+
);
361+
});
362+
250363
test("should setup ViteDevServer in development mode", async () => {
251364
process.env.NODE_ENV = "development";
252365
const plugin = new ServerPlugin({});

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)