From a7eb24d3dd401dd22313845f0ba13318dbd805a6 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Thu, 20 Aug 2026 17:30:42 +0300 Subject: [PATCH] fix(playground): prevent eval-server port conflicts Select an available loopback port when the preferred evaluation-server port is occupied, while keeping Vite's proxy aligned with the selected port. Force dependency re-optimization when serving from the sibling clicky-ui source to prevent stale package paths. --- web/apps/playground/plugins/eval-server.ts | 44 +++++++++++++- web/apps/playground/test/viteConfig.test.ts | 64 +++++++++++++++++++++ web/apps/playground/vite.config.ts | 38 ++++++++++-- 3 files changed, 138 insertions(+), 8 deletions(-) create mode 100644 web/apps/playground/test/viteConfig.test.ts diff --git a/web/apps/playground/plugins/eval-server.ts b/web/apps/playground/plugins/eval-server.ts index cce616911..d350ff70a 100644 --- a/web/apps/playground/plugins/eval-server.ts +++ b/web/apps/playground/plugins/eval-server.ts @@ -1,13 +1,53 @@ import { spawn, type ChildProcess } from "node:child_process"; +import { createServer } from "node:net"; import type { Plugin } from "vite"; export interface EvalServerOptions { /** Repository root, where `go run` is invoked. */ repoRoot: string; + /** Loopback host the Go server listens on. */ + host: string; /** Port the Go server listens on. */ port: number; } +interface AvailablePortOptions { + host: string; + preferredPort: number; +} + +export async function findAvailablePort({ + host, + preferredPort, +}: AvailablePortOptions): Promise { + const preferred = await tryPort(host, preferredPort); + if (preferred !== undefined) return preferred; + + const available = await tryPort(host, 0); + if (available === undefined) throw new Error("operating system did not allocate a loopback port"); + return available; +} + +function tryPort(host: string, port: number): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.once("error", (error: NodeJS.ErrnoException) => { + if (error.code === "EADDRINUSE") resolve(undefined); + else reject(error); + }); + server.listen(port, host, () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error(`could not resolve allocated loopback port for ${host}`)); + return; + } + server.close((error) => (error ? reject(error) : resolve(address.port))); + }); + }); +} + /** * Runs `go run ./cmd/playground` alongside the dev server, so evaluation goes * through the real gomplate engine rather than a reimplementation in the @@ -17,7 +57,7 @@ export interface EvalServerOptions { * when attaching a debugger, or when iterating on Go code that would otherwise * be recompiled on every Vite restart. */ -export function evalServer({ repoRoot, port }: EvalServerOptions): Plugin { +export function evalServer({ repoRoot, host, port }: EvalServerOptions): Plugin { let child: ChildProcess | undefined; const stop = () => { @@ -41,7 +81,7 @@ export function evalServer({ repoRoot, port }: EvalServerOptions): Plugin { return; } - child = spawn("go", ["run", "./cmd/playground", "-addr", `:${port}`], { + child = spawn("go", ["run", "./cmd/playground", "-addr", `${host}:${port}`], { cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"], }); diff --git a/web/apps/playground/test/viteConfig.test.ts b/web/apps/playground/test/viteConfig.test.ts new file mode 100644 index 000000000..9b4dd809b --- /dev/null +++ b/web/apps/playground/test/viteConfig.test.ts @@ -0,0 +1,64 @@ +import { createServer } from "node:net"; +import { describe, expect, it } from "vitest"; + +import { findAvailablePort } from "../plugins/eval-server"; +import { createPlaygroundConfig } from "../vite.config"; + +const SELECTED_EVAL_PORT = 49_153; + +describe("playground Vite configuration", () => { + it.each([ + { command: "serve" as const, mode: "development", clickySourceAvailable: true, expected: true }, + { command: "serve" as const, mode: "test", clickySourceAvailable: true, expected: false }, + { command: "serve" as const, mode: "development", clickySourceAvailable: false, expected: false }, + { command: "build" as const, mode: "production", clickySourceAvailable: true, expected: false }, + ])( + "sets dependency re-optimization to $expected for command=$command mode=$mode source=$clickySourceAvailable", + ({ command, mode, clickySourceAvailable, expected }) => { + const config = createPlaygroundConfig({ + command, + mode, + clickySourceAvailable, + evalPort: SELECTED_EVAL_PORT, + }); + + expect(config.optimizeDeps?.force).toBe(expected); + }, + ); + + it("proxies API requests to the selected eval-server port", () => { + const config = createPlaygroundConfig({ + command: "serve", + mode: "development", + clickySourceAvailable: false, + evalPort: SELECTED_EVAL_PORT, + }); + + expect(config.server?.proxy?.["/api"]).toMatchObject({ + target: `http://127.0.0.1:${SELECTED_EVAL_PORT}`, + }); + }); + + it("selects another loopback port when the preferred port is occupied", async () => { + const occupied = createServer(); + await new Promise((resolve, reject) => { + occupied.once("error", reject); + occupied.listen(0, "127.0.0.1", resolve); + }); + + try { + const address = occupied.address(); + if (!address || typeof address === "string") throw new Error("test listener has no TCP port"); + const selectedPort = await findAvailablePort({ + host: "127.0.0.1", + preferredPort: address.port, + }); + + expect(selectedPort).not.toBe(address.port); + } finally { + await new Promise((resolve, reject) => { + occupied.close((error) => (error ? reject(error) : resolve())); + }); + } + }); +}); diff --git a/web/apps/playground/vite.config.ts b/web/apps/playground/vite.config.ts index ae9212238..4c9b9babf 100644 --- a/web/apps/playground/vite.config.ts +++ b/web/apps/playground/vite.config.ts @@ -3,13 +3,14 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; -import { defineConfig } from "vite"; +import { defineConfig, type UserConfig } from "vite"; -import { evalServer } from "./plugins/eval-server"; +import { evalServer, findAvailablePort } from "./plugins/eval-server"; const root = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(root, "../../.."); +const EVAL_HOST = "127.0.0.1"; const EVAL_PORT = 8321; const DEV_PORT = 5280; @@ -37,14 +38,26 @@ const clickyAliases = [ { find: /^@flanksource\/clicky-ui$/, replacement: resolve(clickySrc, "index.ts") }, ]; -export default defineConfig(({ command, mode }) => { +interface PlaygroundConfigOptions { + command: "build" | "serve"; + mode: string; + clickySourceAvailable: boolean; + evalPort: number; +} + +export function createPlaygroundConfig({ + command, + mode, + clickySourceAvailable, + evalPort, +}: PlaygroundConfigOptions): UserConfig { // Vitest also runs in `serve`, and it is the one mode that must not alias: // tests assert against the surface the package publishes, not against // whatever a sibling checkout happens to have mid-edit. - const useClickySource = clickySourceAvailable && mode !== "test"; + const useClickySource = command === "serve" && clickySourceAvailable && mode !== "test"; return { - plugins: [react(), tailwindcss(), evalServer({ repoRoot, port: EVAL_PORT })], + plugins: [react(), tailwindcss(), evalServer({ repoRoot, host: EVAL_HOST, port: evalPort })], resolve: { dedupe: ["react", "react-dom"], alias: command === "serve" && useClickySource ? clickyAliases : [], @@ -54,7 +67,7 @@ export default defineConfig(({ command, mode }) => { strictPort: true, proxy: { "/api": { - target: `http://127.0.0.1:${EVAL_PORT}`, + target: `http://${EVAL_HOST}:${evalPort}`, changeOrigin: false, }, }, @@ -62,10 +75,23 @@ export default defineConfig(({ command, mode }) => { fs: { allow: [root, resolve(root, "../.."), ...(useClickySource ? [clickySrc] : [])] }, }, optimizeDeps: { + // The sibling lockfile is outside Vite's cache inputs, so dependency + // paths can otherwise remain stale after clicky-ui upgrades a package. + force: useClickySource, exclude: [ "@flanksource/gomplate-lang", ...(useClickySource ? ["@flanksource/clicky-ui"] : []), ], }, }; +} + +export default defineConfig(async ({ command, mode }) => { + const managesEvalServer = process.env.GOMPLATE_PLAYGROUND_SERVER !== "0"; + const evalPort = + command === "serve" && mode !== "test" && managesEvalServer + ? await findAvailablePort({ host: EVAL_HOST, preferredPort: EVAL_PORT }) + : EVAL_PORT; + + return createPlaygroundConfig({ command, mode, clickySourceAvailable, evalPort }); });