authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-01 23:32:52-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-01 23:53:31-08:00
logf239c90628cefc37f07ed93783a8404fe3f3f8b3
treebb1993a52c43aeee18cc28fbacec0109c50c5f13
parent991dd05e72a3afd1dccbcc4522a7e4b98260cae3

Auto-select next available dev ports per mode

- Probe localhost ports and increment offsets when base ports are taken - Resolve server/web offsets by mode while honoring explicit port/dev-url overrides - Add tests for offset selection and per-mode fallback behavior

2 files changed, 246 insertions(+), 14 deletions(-)

scripts/dev-runner.mjs+159-14
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { spawn } from "node:child_process";1import { spawn } from "node:child_process";
2import net from "node:net";
2import { homedir } from "node:os";3import { homedir } from "node:os";
3import path from "node:path";4import path from "node:path";
4import { pathToFileURL } from "node:url";5import { pathToFileURL } from "node:url";
...@@ -6,6 +7,7 @@ import { pathToFileURL } from "node:url";...@@ -6,6 +7,7 @@ import { pathToFileURL } from "node:url";
6const BASE_SERVER_PORT = 3773;7const BASE_SERVER_PORT = 3773;
7const BASE_WEB_PORT = 5733;8const BASE_WEB_PORT = 5733;
8const MAX_HASH_OFFSET = 3000;9const MAX_HASH_OFFSET = 3000;
10const MAX_PORT = 65535;
9export const DEFAULT_DEV_STATE_DIR = path.join(homedir(), ".t3", "dev");11export const DEFAULT_DEV_STATE_DIR = path.join(homedir(), ".t3", "dev");
10const MODE_ARGS = {12const MODE_ARGS = {
11 dev: [13 dev: [
...@@ -209,7 +211,127 @@ export function createDevRunnerEnv({ mode, env, offset, envOverrides }) {...@@ -209,7 +211,127 @@ export function createDevRunnerEnv({ mode, env, offset, envOverrides }) {
209 return output;211 return output;
210}212}
211213
212export function runDevRunner(argv = process.argv) {214function portPairForOffset(offset) {
215 return {
216 serverPort: BASE_SERVER_PORT + offset,
217 webPort: BASE_WEB_PORT + offset,
218 };
219}
220
221async function canListenOnHost(port, host) {
222 return await new Promise((resolve) => {
223 const server = net.createServer();
224 server.unref();
225 server.once("error", (error) => {
226 const code = error?.code;
227 if (code === "EADDRNOTAVAIL") {
228 // Host family unavailable on this machine (for example, no IPv6 loopback).
229 resolve(true);
230 return;
231 }
232 resolve(false);
233 });
234 server.once("listening", () => {
235 server.close(() => {
236 resolve(true);
237 });
238 });
239 server.listen({ host, port });
240 });
241}
242
243async function isPortAvailable(port) {
244 // Vite/dev traffic uses localhost loopback. Verify both IPv4 and IPv6 loopback
245 // so we don't pick a port that fails later on one address family.
246 const [ipv4, ipv6] = await Promise.all([
247 canListenOnHost(port, "127.0.0.1"),
248 canListenOnHost(port, "::1"),
249 ]);
250 return ipv4 && ipv6;
251}
252
253const defaultCheckPortAvailability = isPortAvailable;
254
255export async function findFirstAvailableOffset({
256 startOffset,
257 requireServerPort,
258 requireWebPort,
259 checkPortAvailability = defaultCheckPortAvailability,
260}) {
261 for (let candidate = startOffset; ; candidate += 1) {
262 const { serverPort, webPort } = portPairForOffset(candidate);
263 if (serverPort > MAX_PORT || webPort > MAX_PORT) {
264 break;
265 }
266
267 const checks = [];
268 if (requireServerPort) {
269 checks.push(checkPortAvailability(serverPort));
270 }
271 if (requireWebPort) {
272 checks.push(checkPortAvailability(webPort));
273 }
274
275 if (checks.length === 0) {
276 return candidate;
277 }
278
279 const availability = await Promise.all(checks);
280 if (availability.every(Boolean)) {
281 return candidate;
282 }
283 }
284
285 throw new Error(
286 `No available dev ports found from offset ${startOffset}. Tried server=${BASE_SERVER_PORT}+n web=${BASE_WEB_PORT}+n up to port ${MAX_PORT}.`,
287 );
288}
289
290export async function resolveModePortOffsets({
291 mode,
292 startOffset,
293 envOverrides,
294 checkPortAvailability,
295}) {
296 const hasExplicitServerPort = typeof envOverrides.T3CODE_PORT === "string";
297 const hasExplicitDevUrl = typeof envOverrides.VITE_DEV_SERVER_URL === "string";
298
299 if (mode === "dev:web") {
300 if (hasExplicitDevUrl) {
301 return { serverOffset: startOffset, webOffset: startOffset };
302 }
303 const webOffset = await findFirstAvailableOffset({
304 startOffset,
305 requireServerPort: false,
306 requireWebPort: true,
307 checkPortAvailability,
308 });
309 return { serverOffset: startOffset, webOffset };
310 }
311
312 if (mode === "dev:server") {
313 if (hasExplicitServerPort) {
314 return { serverOffset: startOffset, webOffset: startOffset };
315 }
316 const serverOffset = await findFirstAvailableOffset({
317 startOffset,
318 requireServerPort: true,
319 requireWebPort: false,
320 checkPortAvailability,
321 });
322 return { serverOffset, webOffset: serverOffset };
323 }
324
325 const sharedOffset = await findFirstAvailableOffset({
326 startOffset,
327 requireServerPort: !hasExplicitServerPort,
328 requireWebPort: !hasExplicitDevUrl,
329 checkPortAvailability,
330 });
331 return { serverOffset: sharedOffset, webOffset: sharedOffset };
332}
333
334export async function runDevRunner(argv = process.argv) {
213 const mode = argv[2];335 const mode = argv[2];
214 const parsedArgs = parseDevRunnerArgs(argv.slice(3));336 const parsedArgs = parseDevRunnerArgs(argv.slice(3));
215 if (!mode || !(mode in MODE_ARGS)) {337 if (!mode || !(mode in MODE_ARGS)) {
...@@ -218,24 +340,49 @@ export function runDevRunner(argv = process.argv) {...@@ -218,24 +340,49 @@ export function runDevRunner(argv = process.argv) {
218 }340 }
219341
220 const { offset, source } = resolveOffset();342 const { offset, source } = resolveOffset();
221 const serverPort = BASE_SERVER_PORT + offset;343 const { serverOffset, webOffset } = await resolveModePortOffsets({
222 const webPort = BASE_WEB_PORT + offset;344 mode,
345 startOffset: offset,
346 envOverrides: parsedArgs.envOverrides,
347 });
223348
224 if (serverPort > 65535 || webPort > 65535) {349 const mergedEnvOverrides = { ...parsedArgs.envOverrides };
225 throw new Error(350 if (!("VITE_DEV_SERVER_URL" in mergedEnvOverrides)) {
226 `Port offset too large (${offset}). Computed ports: server=${serverPort}, web=${webPort}`,351 const { webPort } = portPairForOffset(webOffset);
227 );352 mergedEnvOverrides.PORT = String(webPort);
353 mergedEnvOverrides.ELECTRON_RENDERER_PORT = String(webPort);
354 mergedEnvOverrides.VITE_DEV_SERVER_URL = `http://localhost:${webPort}`;
228 }355 }
229356
230 const env = createDevRunnerEnv({357 const env = createDevRunnerEnv({
231 mode,358 mode,
232 env: process.env,359 env: process.env,
233 offset,360 offset: serverOffset,
234 envOverrides: parsedArgs.envOverrides,361 envOverrides: mergedEnvOverrides,
235 });362 });
236363
364 const parsedServerPort = Number(env.T3CODE_PORT);
365 const parsedWebPort = Number(env.PORT);
366 if (
367 !Number.isInteger(parsedServerPort) ||
368 parsedServerPort < 1 ||
369 parsedServerPort > MAX_PORT ||
370 !Number.isInteger(parsedWebPort) ||
371 parsedWebPort < 1 ||
372 parsedWebPort > MAX_PORT
373 ) {
374 throw new Error(
375 `Invalid computed dev ports: server='${String(env.T3CODE_PORT)}' web='${String(env.PORT)}'.`,
376 );
377 }
378
379 const selectionSuffix =
380 serverOffset !== offset || webOffset !== offset
381 ? ` selectedOffset(server=${serverOffset},web=${webOffset})`
382 : "";
383
237 console.info(384 console.info(
238 `[dev-runner] mode=${mode} source=${source} serverPort=${serverPort} webPort=${webPort} stateDir=${env.T3CODE_STATE_DIR}`,385 `[dev-runner] mode=${mode} source=${source}${selectionSuffix} serverPort=${parsedServerPort} webPort=${parsedWebPort} stateDir=${env.T3CODE_STATE_DIR}`,
239 );386 );
240387
241 if (parsedArgs.isDryRun) {388 if (parsedArgs.isDryRun) {
...@@ -265,10 +412,8 @@ export function runDevRunner(argv = process.argv) {...@@ -265,10 +412,8 @@ export function runDevRunner(argv = process.argv) {
265const entrypointPath = process.argv[1] ? pathToFileURL(process.argv[1]).href : "";412const entrypointPath = process.argv[1] ? pathToFileURL(process.argv[1]).href : "";
266413
267if (import.meta.url === entrypointPath) {414if (import.meta.url === entrypointPath) {
268 try {415 runDevRunner().catch((error) => {
269 runDevRunner();
270 } catch (error) {
271 console.error("[dev-runner]", error);416 console.error("[dev-runner]", error);
272 process.exit(1);417 process.exit(1);
273 }418 });
274}419}
scripts/dev-runner.test.ts+87
...@@ -3,7 +3,9 @@ import { describe, expect, it } from "vitest";...@@ -3,7 +3,9 @@ import { describe, expect, it } from "vitest";
3import {3import {
4 DEFAULT_DEV_STATE_DIR,4 DEFAULT_DEV_STATE_DIR,
5 createDevRunnerEnv,5 createDevRunnerEnv,
6 findFirstAvailableOffset,
6 parseDevRunnerArgs,7 parseDevRunnerArgs,
8 resolveModePortOffsets,
7} from "./dev-runner.mjs";9} from "./dev-runner.mjs";
810
9describe("parseDevRunnerArgs", () => {11describe("parseDevRunnerArgs", () => {
...@@ -123,3 +125,88 @@ describe("createDevRunnerEnv", () => {...@@ -123,3 +125,88 @@ describe("createDevRunnerEnv", () => {
123 ).toThrow("Invalid T3CODE_PORT override");125 ).toThrow("Invalid T3CODE_PORT override");
124 });126 });
125});127});
128
129describe("findFirstAvailableOffset", () => {
130 it("returns the starting offset when required ports are available", async () => {
131 const offset = await findFirstAvailableOffset({
132 startOffset: 0,
133 requireServerPort: true,
134 requireWebPort: true,
135 checkPortAvailability: async () => true,
136 });
137
138 expect(offset).toBe(0);
139 });
140
141 it("advances until all required ports are available", async () => {
142 const taken = new Set([3773, 5733, 3774, 5734]);
143 const offset = await findFirstAvailableOffset({
144 startOffset: 0,
145 requireServerPort: true,
146 requireWebPort: true,
147 checkPortAvailability: async (port) => !taken.has(port),
148 });
149
150 expect(offset).toBe(2);
151 });
152});
153
154describe("resolveModePortOffsets", () => {
155 it("uses a shared fallback offset for dev mode", async () => {
156 const taken = new Set([3773, 5733]);
157 const offsets = await resolveModePortOffsets({
158 mode: "dev",
159 startOffset: 0,
160 envOverrides: {},
161 checkPortAvailability: async (port) => !taken.has(port),
162 });
163
164 expect(offsets).toEqual({ serverOffset: 1, webOffset: 1 });
165 });
166
167 it("keeps server offset stable for dev:web and only shifts web offset", async () => {
168 const taken = new Set([5733]);
169 const offsets = await resolveModePortOffsets({
170 mode: "dev:web",
171 startOffset: 0,
172 envOverrides: {},
173 checkPortAvailability: async (port) => !taken.has(port),
174 });
175
176 expect(offsets).toEqual({ serverOffset: 0, webOffset: 1 });
177 });
178
179 it("shifts only server offset for dev:server", async () => {
180 const taken = new Set([3773]);
181 const offsets = await resolveModePortOffsets({
182 mode: "dev:server",
183 startOffset: 0,
184 envOverrides: {},
185 checkPortAvailability: async (port) => !taken.has(port),
186 });
187
188 expect(offsets).toEqual({ serverOffset: 1, webOffset: 1 });
189 });
190
191 it("respects explicit dev-url override for dev:web", async () => {
192 const offsets = await resolveModePortOffsets({
193 mode: "dev:web",
194 startOffset: 0,
195 envOverrides: { VITE_DEV_SERVER_URL: "http://localhost:9999" },
196 checkPortAvailability: async () => false,
197 });
198
199 expect(offsets).toEqual({ serverOffset: 0, webOffset: 0 });
200 });
201
202 it("respects explicit server port override for dev:server", async () => {
203 const offsets = await resolveModePortOffsets({
204 mode: "dev:server",
205 startOffset: 0,
206 envOverrides: { T3CODE_PORT: "4888" },
207 checkPortAvailability: async () => false,
208 });
209
210 expect(offsets).toEqual({ serverOffset: 0, webOffset: 0 });
211 });
212});