From fa5326c3858ce3651abf99f709d0ada401b99eda Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Thu, 25 Jun 2026 15:26:08 -0400 Subject: [PATCH 1/2] feat(eve): reconnect the CLI dev TUI Signed-off-by: Rui Conti --- .changeset/reconnect-dev-tui.md | 5 + docs/reference/cli.md | 2 +- .../eve/src/cli/dev/tui/status-line.test.ts | 12 + packages/eve/src/cli/dev/tui/status-line.ts | 20 +- .../src/cli/dev/tui/terminal-renderer.test.ts | 4 + .../eve/src/cli/dev/tui/terminal-renderer.ts | 7 +- packages/eve/src/cli/dev/tui/tui.test.ts | 43 ++ packages/eve/src/cli/run.test.ts | 75 ++- packages/eve/src/cli/run.ts | 41 +- packages/eve/src/evals/cli/eval.ts | 15 +- packages/eve/src/internal/nitro/host.ts | 5 +- .../host/dev-server-state.integration.test.ts | 58 +++ .../internal/nitro/host/dev-server-state.ts | 69 +++ .../host/start-development-server.test.ts | 423 ++++++++++++++--- .../nitro/host/start-development-server.ts | 433 +++++++++--------- packages/eve/src/internal/nitro/host/types.ts | 31 +- .../eve/src/shared/eve-server-health.test.ts | 23 + packages/eve/src/shared/eve-server-health.ts | 26 ++ .../eve/src/shared/network-address.test.ts | 40 +- packages/eve/src/shared/network-address.ts | 32 ++ packages/eve/src/shared/result.test.ts | 22 + packages/eve/src/shared/result.ts | 14 + .../eve/test/scenarios/cli.scenario.test.ts | 9 +- 23 files changed, 1079 insertions(+), 330 deletions(-) create mode 100644 .changeset/reconnect-dev-tui.md create mode 100644 packages/eve/src/cli/dev/tui/tui.test.ts create mode 100644 packages/eve/src/internal/nitro/host/dev-server-state.integration.test.ts create mode 100644 packages/eve/src/internal/nitro/host/dev-server-state.ts create mode 100644 packages/eve/src/shared/eve-server-health.test.ts create mode 100644 packages/eve/src/shared/eve-server-health.ts create mode 100644 packages/eve/src/shared/result.test.ts create mode 100644 packages/eve/src/shared/result.ts diff --git a/.changeset/reconnect-dev-tui.md b/.changeset/reconnect-dev-tui.md new file mode 100644 index 000000000..cc91926ed --- /dev/null +++ b/.changeset/reconnect-dev-tui.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Running `eve dev` interactively now reconnects to the healthy loopback dev server recorded for the same app root, with a fresh session for each attached terminal UI. Eve replaces stale or malformed state when it starts a new server. `--host`, `--port`, or `PORT` skips reconnection and reports a healthy recorded server instead. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 853491d4e..c6e5c36ff 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -109,7 +109,7 @@ Pass a bare URL as the only argument and the UI connects to that server instead | `--context-size ` | number | none | Model context window size, shown as a usage percentage | | `--logs ` | enum | `stderr` | Server/agent logs to show: `all` \| `stderr` \| `sandbox` \| `none` | -Local dev writes the active server process ID to `.eve/dev-process.pid`. If another `eve dev` starts for the same agent while that process is still running, eve exits with a message that includes the command to stop the existing server. +Local dev records the last ready URL per resolved app root in `.eve/dev-server-state.v1.json`. A second interactive `eve dev` reconnects only when that URL is loopback and healthy; each terminal UI creates a fresh client session while sharing the server process. A stale or malformed record is replaced when eve starts a new server. Passing `--host`, `--port`, or a `PORT` environment value skips reconnection and reports a healthy recorded server instead. Local dev keeps immutable runtime source snapshots under `.eve/dev-runtime/snapshots/` so in-flight sessions hold a consistent code revision while new prompts pick up rebuilds. On startup, `eve dev` prunes stale runtime snapshots and old local sandbox templates in the background. For manual cleanup, stop `eve dev` and delete `.eve/dev-runtime/snapshots/` or `.eve/sandbox-cache/local/templates/`. diff --git a/packages/eve/src/cli/dev/tui/status-line.test.ts b/packages/eve/src/cli/dev/tui/status-line.test.ts index 8282ef1aa..b757b771f 100644 --- a/packages/eve/src/cli/dev/tui/status-line.test.ts +++ b/packages/eve/src/cli/dev/tui/status-line.test.ts @@ -37,6 +37,18 @@ function deployedRemote( } describe("buildStatusLine", () => { + it("renders the local server port as a gray reverse-video badge before the model", () => { + const line = buildStatusLine({ + serverPort: "3000", + model: "openai/gpt-5.5", + theme, + width: 120, + })!; + + expect(stripAnsi(line)).toBe(" :3000 · openai/gpt-5.5"); + expect(line).toContain("\x1b[7m\x1b[90m :3000 \x1b[39m\x1b[27m"); + }); + it("renders all segments in order with dot separators", () => { const line = buildStatusLine({ model: "anthropic/claude-sonnet-4-6", diff --git a/packages/eve/src/cli/dev/tui/status-line.ts b/packages/eve/src/cli/dev/tui/status-line.ts index b49c70c60..c4ccb1596 100644 --- a/packages/eve/src/cli/dev/tui/status-line.ts +++ b/packages/eve/src/cli/dev/tui/status-line.ts @@ -7,6 +7,8 @@ import type { VercelStatusSnapshot } from "./vercel-status.js"; import type { ModelEndpointStatus } from "#shared/model-endpoint-status.js"; export interface StatusLineInput { + /** Port of the connected local development server; omitted for remote sessions. */ + serverPort?: string; /** Resolved model slug, e.g. "anthropic/claude-sonnet-4-6"; absent when `/eve/v1/info` failed. */ model?: string; /** Preformatted token-flow segment (formatTokenFlow output), e.g. `↑ 394.4K ↓ 4.3K`. */ @@ -37,6 +39,14 @@ function renderModel( return input.theme.colors.dim(model); } +function renderServerPort( + input: Pick, +): string | undefined { + if (input.remote !== undefined || input.serverPort === undefined) return undefined; + const c = input.theme.colors; + return c.inverse(c.gray(` :${input.serverPort} `)); +} + function renderEndpoint( input: Pick, ): string | undefined { @@ -51,7 +61,7 @@ function renderEndpoint( } /** - * Builds `↗ project (environment) · model · tokens · /deploy pending`. + * Builds `↗ project (environment) · :port · model · tokens · /deploy pending`. * Remote sessions omit endpoint state and keep their badge as the final * narrow-width fallback. Returns undefined when every segment is empty. */ @@ -60,6 +70,7 @@ export function buildStatusLine(input: StatusLineInput): string | undefined { const c = theme.colors; const logLevel = input.logLevel === undefined ? undefined : c.cyan(`logs: ${input.logLevel}`); + const serverPort = renderServerPort(input); const model = renderModel(input); const tokens = input.tokens === undefined ? undefined : c.dim(input.tokens); const pending = input.vercel?.pendingDeploy ? c.yellow("/deploy pending") : undefined; @@ -74,9 +85,10 @@ export function buildStatusLine(input: StatusLineInput): string | undefined { // leads every variant and gets the final stand-alone fallback. Without a // remote, the logs hint retains its previous priority. const variants = [ - compose([remote?.full, logLevel, model, tokens, endpoint, pending]), - compose([remote?.full, logLevel, model, tokens, pending]), - compose([remote?.full, logLevel, tokens, pending]), + compose([remote?.full, logLevel, serverPort, model, tokens, endpoint, pending]), + compose([remote?.full, logLevel, serverPort, model, tokens, pending]), + compose([remote?.full, logLevel, serverPort, tokens, pending]), + compose([remote?.full, logLevel, serverPort]), compose([remote?.full, logLevel]), compose([remote?.badge, logLevel]), compose([remote?.badge]), diff --git a/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts b/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts index a51fdd4fb..610ebef51 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts @@ -2714,7 +2714,11 @@ describe("TerminalRenderer status line", () => { const promptRow = lines.findIndex((line) => line.includes("❯")); expect(promptRow).toBeGreaterThan(-1); const statusRow = lines.slice(promptRow + 1).join("\n"); + expect(statusRow).toContain(":3000"); expect(statusRow).toContain("anthropic/claude-sonnet-4-6"); + expect(statusRow.indexOf(":3000")).toBeLessThan( + statusRow.indexOf("anthropic/claude-sonnet-4-6"), + ); // The linked project folds into the connected gateway label. expect(statusRow).toContain("AI Gateway (my-agent)"); expect(statusRow).not.toContain("⚠ AI Gateway"); diff --git a/packages/eve/src/cli/dev/tui/terminal-renderer.ts b/packages/eve/src/cli/dev/tui/terminal-renderer.ts index b045f3900..8634639ec 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.ts @@ -2808,7 +2808,7 @@ export class TerminalRenderer implements AgentTUIRenderer { } /** - * Appends the persistent bottom status line (model · tokens · Vercel link · + * Appends the persistent bottom status line (port · model · tokens · Vercel link · * pending deploy) when any segment has content. */ #pushStatusLine(rows: string[], width: number): void { @@ -2819,6 +2819,11 @@ export class TerminalRenderer implements AgentTUIRenderer { width: contentWidth, }; if (this.#logLevelHintActive) input.logLevel = this.#logs; + const serverUrl = this.#agentHeader?.serverUrl; + if (serverUrl !== undefined && this.#remoteConnection === undefined) { + const serverPort = new URL(serverUrl).port; + if (serverPort.length > 0) input.serverPort = serverPort; + } const model = this.#agentHeader?.info?.agent.model.id; if (model !== undefined) input.model = model; // The runner resolves model-provider state with `/info` before caching this diff --git a/packages/eve/src/cli/dev/tui/tui.test.ts b/packages/eve/src/cli/dev/tui/tui.test.ts new file mode 100644 index 000000000..6a13f87f7 --- /dev/null +++ b/packages/eve/src/cli/dev/tui/tui.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { EveTUIRunnerOptions } from "./runner.js"; + +const mocks = vi.hoisted<{ runnerOptions: EveTUIRunnerOptions[] }>(() => ({ + runnerOptions: [], +})); + +vi.mock("./runner.js", () => ({ + EveTUIRunner: class { + constructor(options: EveTUIRunnerOptions) { + mocks.runnerOptions.push(options); + } + + async run(): Promise {} + }, +})); + +import { runDevelopmentTui, type DevelopmentTuiTarget } from "./tui.js"; + +describe("runDevelopmentTui", () => { + beforeEach(() => { + mocks.runnerOptions.length = 0; + }); + + it("creates a fresh client session for every TUI attached to the same server", async () => { + const target = { + kind: "local", + serverUrl: "http://127.0.0.1:4321/", + workspaceRoot: "/tmp/app", + } satisfies DevelopmentTuiTarget; + await runDevelopmentTui({ target }); + await runDevelopmentTui({ target }); + + expect(mocks.runnerOptions).toHaveLength(2); + const [first, second] = mocks.runnerOptions; + if (first === undefined || second === undefined) { + throw new Error("Expected two TUI runner invocations."); + } + expect(first.client).not.toBe(second.client); + expect(first.session).not.toBe(second.session); + }); +}); diff --git a/packages/eve/src/cli/run.test.ts b/packages/eve/src/cli/run.test.ts index 218a88ef4..2a3c4f104 100644 --- a/packages/eve/src/cli/run.test.ts +++ b/packages/eve/src/cli/run.test.ts @@ -163,12 +163,19 @@ describe("eve dev boot progress", () => { const close = vi.fn(async () => {}); let hostReporter: DevelopmentServerOptions["onBootProgress"] = undefined; let tuiReporter: RunDevelopmentTuiInput["onBootProgress"] = undefined; - const startHost = vi.fn(async (_appRoot: string, options?: DevelopmentServerOptions) => { - hostReporter = options?.onBootProgress; - hostReporter?.({ phase: "compiling agent", type: "phase-started" }); - hostReporter?.({ elapsedMs: 1, phase: "compiling agent", type: "phase-finished" }); - return { close, url: "http://127.0.0.1:2000" }; - }); + const startHost = vi.fn((_appRoot: string, options?: DevelopmentServerOptions) => ({ + start: async () => { + hostReporter = options?.onBootProgress; + hostReporter?.({ phase: "compiling agent", type: "phase-started" }); + hostReporter?.({ elapsedMs: 1, phase: "compiling agent", type: "phase-finished" }); + return { + kind: "started" as const, + appRoot: "/canonical/app", + url: "http://127.0.0.1:2000", + }; + }, + close, + })); const runDevelopmentTui = vi.fn(async (input: RunDevelopmentTuiInput) => { tuiReporter = input.onBootProgress; throw new Error("TUI startup failed"); @@ -195,6 +202,62 @@ describe("eve dev boot progress", () => { }); }); +describe("eve dev local server ownership", () => { + it("uses the host's canonical root and leaves an attached server running", async () => { + const startHost = vi.fn(() => ({ + start: async () => ({ + kind: "existing" as const, + appRoot: "/canonical/app", + url: "http://127.0.0.1:4321/", + }), + close: async () => {}, + })); + const runDevelopmentTui = vi.fn(async () => {}); + + await withInteractiveTerminal(() => + runCli(["dev"], { error: () => {}, log: () => {} }, { runDevelopmentTui, startHost }), + ); + + expect(startHost).toHaveBeenCalledWith(expect.any(String), { + existing: "attach-if-unconfigured", + host: undefined, + onBootProgress: expect.any(Function), + port: undefined, + }); + expect(runDevelopmentTui).toHaveBeenCalledWith( + expect.objectContaining({ + name: "App", + target: { + kind: "local", + serverUrl: "http://127.0.0.1:4321/", + workspaceRoot: "/canonical/app", + }, + }), + ); + }); + + it("closes a server started for the interactive TUI", async () => { + const close = vi.fn(async () => {}); + const startHost = vi.fn(() => ({ + start: async () => ({ + kind: "started" as const, + appRoot: "/canonical/app", + url: "http://127.0.0.1:4321/", + }), + close, + })); + + await withInteractiveTerminal(() => + runCli( + ["dev"], + { error: () => {}, log: () => {} }, + { runDevelopmentTui: vi.fn(async () => {}), startHost }, + ), + ); + expect(close).toHaveBeenCalledOnce(); + }); +}); + describe("resolveDevUiMode", () => { it("defaults to the terminal UI in an interactive terminal", () => { expect(resolveDevUiMode({ options: {}, interactive: true })).toBe("tui"); diff --git a/packages/eve/src/cli/run.ts b/packages/eve/src/cli/run.ts index 5cf53e378..61a00f1e7 100644 --- a/packages/eve/src/cli/run.ts +++ b/packages/eve/src/cli/run.ts @@ -13,7 +13,7 @@ import { startCliLiveRow } from "#cli/ui/live-row.js"; import { createCliTheme, renderCliTaggedLine } from "#cli/ui/output.js"; import { createLogger } from "#internal/logging.js"; import type { - DevelopmentServerHandle, + DevelopmentServer, DevelopmentServerOptions, ProductionServerHandle, } from "#internal/nitro/host/types.js"; @@ -64,7 +64,7 @@ interface CliRuntimeDependencies { options: EvalCliOptions, logger: CliLogger, ): Promise; - startHost(appRoot: string, options?: DevelopmentServerOptions): Promise; + startHost(appRoot: string, options?: DevelopmentServerOptions): DevelopmentServer; startProductionHost( appRoot: string, options?: { @@ -131,7 +131,7 @@ async function loadRunEvalCommand(): Promise { - return (await import("#internal/nitro/host.js")).startDevelopmentServer; + return (await import("#internal/nitro/host.js")).createDevelopmentServer; } async function loadStartProductionHost(): Promise { @@ -487,11 +487,13 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm if (options.input !== undefined && mode === "headless") { throw new InvalidArgumentError("--input requires the interactive UI."); } - const { loadDevelopmentEnvironmentFiles } = await import("#cli/dev/environment.js"); - - loadDevelopmentEnvironmentFiles(appRoot); - - const runInteractiveUi = async (serverUrl: string, report?: DevBootProgressReporter) => { + const runInteractiveUi = async ( + input: { + readonly appRoot?: string; + readonly serverUrl: string; + }, + report?: DevBootProgressReporter, + ): Promise => { const runDevelopmentTui = await devBootPhase( "loading interactive UI", async () => runtime.runDevelopmentTui ?? (await loadRunDevelopmentTui()), @@ -500,8 +502,12 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm const display = resolveTuiDisplayOptions(options); const target: DevelopmentTuiTarget = remoteServerUrl === undefined - ? { kind: "local", serverUrl, workspaceRoot: appRoot } - : { kind: "remote", serverUrl, workspaceRoot: appRoot }; + ? { + kind: "local", + serverUrl: input.serverUrl, + workspaceRoot: input.appRoot ?? appRoot, + } + : { kind: "remote", serverUrl: input.serverUrl, workspaceRoot: appRoot }; const title = resolveTuiTitle({ name: options.name, target }); if (title !== undefined) display.name = title; const tuiInput: RunDevelopmentTuiInput = { @@ -514,6 +520,8 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm }; if (remoteServerUrl) { + const { loadDevelopmentEnvironmentFiles } = await import("#cli/dev/environment.js"); + loadDevelopmentEnvironmentFiles(appRoot); logger.log(`↗ remote mode targeting ${theme.info(new URL(remoteServerUrl).host)}`); if (mode === "headless") { @@ -528,7 +536,7 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm } logger.log(""); - await runInteractiveUi(remoteServerUrl); + await runInteractiveUi({ serverUrl: remoteServerUrl }); return; } @@ -539,23 +547,26 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm buildProgress?.update("Building your agent"); let closed = false; - let server: DevelopmentServerHandle | undefined; + let server: DevelopmentServer | undefined; const closeServer = async () => { if (closed || server === undefined) { return; } closed = true; + // No-op when this instance attached to a server another process owns. await server.close(); }; try { const startHost = runtime.startHost ?? (await loadStartHost()); - server = await startHost(appRoot, { + server = startHost(appRoot, { + existing: mode === "tui" ? "attach-if-unconfigured" : "reject", host: options.host, onBootProgress, port: options.port, }); + const handle = await server.start(); // The terminal UI's header already shows the server URL, and startup // no longer clears the screen, so the line would linger as noise. @@ -563,7 +574,7 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm if (mode !== "tui") { logger.log( renderCliTaggedLine(theme, { - message: `server listening at ${server.url}`, + message: `server listening at ${handle.url}`, tag: "dev", tone: "success", }), @@ -589,7 +600,7 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm }); } - await runInteractiveUi(server.url, onBootProgress); + await runInteractiveUi({ appRoot: handle.appRoot, serverUrl: handle.url }, onBootProgress); } finally { buildProgress?.stop(); await closeServer(); diff --git a/packages/eve/src/evals/cli/eval.ts b/packages/eve/src/evals/cli/eval.ts index e84a13045..4bc1016e5 100644 --- a/packages/eve/src/evals/cli/eval.ts +++ b/packages/eve/src/evals/cli/eval.ts @@ -3,7 +3,7 @@ import { basename, join } from "node:path"; import { loadDevelopmentEnvironmentFiles } from "#cli/dev/environment.js"; import { resolveApplicationRoot } from "#internal/application/paths.js"; -import { type DevelopmentServerHandle, startDevelopmentServer } from "#internal/nitro/host.js"; +import { createDevelopmentServer, type DevelopmentServer } from "#internal/nitro/host.js"; import { createEvalClient } from "#evals/cli/eval-client.js"; import { discoverAndImportEvals, discoverEvalConfig } from "#evals/runner/discover.js"; import { runEvals } from "#evals/runner/run-evals.js"; @@ -90,7 +90,7 @@ export async function runEvalCommand( } // Resolve target - let server: DevelopmentServerHandle | undefined; + let devServer: DevelopmentServer | undefined; let target: EveEvalTargetHandle; let client: Awaited>; @@ -107,13 +107,14 @@ export async function runEvalCommand( url: options.url, }); } else { - server = await startDevelopmentServer(appRoot, { host: "127.0.0.1", port: 0 }); - client = await createEvalClient({ kind: "local", url: server.url }); + devServer = createDevelopmentServer(appRoot, { host: "127.0.0.1", port: 0 }); + const started = await devServer.start(); + client = await createEvalClient({ kind: "local", url: started.url }); target = await resolveEvalTargetHandle({ client, expectedAgentName: await readExpectedAgentName(appRoot), kind: "local", - url: server.url, + url: started.url, }); } @@ -151,8 +152,8 @@ export async function runEvalCommand( process.exitCode = 1; } } finally { - if (server) { - await server.close(); + if (devServer) { + await devServer.close(); } } diff --git a/packages/eve/src/internal/nitro/host.ts b/packages/eve/src/internal/nitro/host.ts index c3d270e54..79dd257ae 100644 --- a/packages/eve/src/internal/nitro/host.ts +++ b/packages/eve/src/internal/nitro/host.ts @@ -1,8 +1,11 @@ export { buildApplication } from "#internal/nitro/host/build-application.js"; -export { startDevelopmentServer } from "#internal/nitro/host/start-development-server.js"; +export { createDevelopmentServer } from "#internal/nitro/host/start-development-server.js"; export { startProductionServer } from "#internal/nitro/host/start-production-server.js"; export type { + DevelopmentServer, DevelopmentServerHandle, DevelopmentServerOptions, + ExistingDevelopmentServer, ProductionServerHandle, + StartedDevelopmentServer, } from "#internal/nitro/host/types.js"; diff --git a/packages/eve/src/internal/nitro/host/dev-server-state.integration.test.ts b/packages/eve/src/internal/nitro/host/dev-server-state.integration.test.ts new file mode 100644 index 000000000..dcb137e6f --- /dev/null +++ b/packages/eve/src/internal/nitro/host/dev-server-state.integration.test.ts @@ -0,0 +1,58 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { DevelopmentServerState } from "#internal/nitro/host/dev-server-state.js"; + +const STATE_FILE_NAME = "dev-server-state.v1.json"; +const temporaryRoots: string[] = []; + +async function createState(): Promise { + const appRoot = await mkdtemp(join(tmpdir(), "eve-dev-server-state-")); + temporaryRoots.push(appRoot); + return new DevelopmentServerState({ appRoot }); +} + +afterEach(async () => { + await Promise.all( + temporaryRoots.splice(0).map((root) => rm(root, { force: true, recursive: true })), + ); +}); + +describe("DevelopmentServerState", () => { + it("returns no URL when no state file exists", async () => { + const state = await createState(); + + await expect(state.read()).resolves.toBeUndefined(); + }); + + it("writes and reads the ready server URL", async () => { + const state = await createState(); + + await state.write("http://127.0.0.1:2000/"); + + await expect(state.read()).resolves.toBe("http://127.0.0.1:2000/"); + await expect(readFile(join(state.appRoot, ".eve", STATE_FILE_NAME), "utf8")).resolves.toBe( + '{"url":"http://127.0.0.1:2000/"}\n', + ); + }); + + it("treats malformed state as stale", async () => { + const state = await createState(); + await mkdir(join(state.appRoot, ".eve"), { recursive: true }); + await writeFile(join(state.appRoot, ".eve", STATE_FILE_NAME), "{ not json", "utf8"); + + await expect(state.read()).resolves.toBeUndefined(); + }); + + it("removes the state record", async () => { + const state = await createState(); + await state.write("http://127.0.0.1:2000/"); + + await state.remove(); + + await expect(state.read()).resolves.toBeUndefined(); + }); +}); diff --git a/packages/eve/src/internal/nitro/host/dev-server-state.ts b/packages/eve/src/internal/nitro/host/dev-server-state.ts new file mode 100644 index 000000000..bbf09b724 --- /dev/null +++ b/packages/eve/src/internal/nitro/host/dev-server-state.ts @@ -0,0 +1,69 @@ +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { z } from "#compiled/zod/index.js"; +import { httpServerUrlSchema } from "#shared/network-address.js"; + +const STATE_FILE_NAME = "dev-server-state.v1.json"; + +const developmentServerStateSchema = z + .object({ + url: httpServerUrlSchema, + }) + .strict(); + +/** + * The last ready development-server URL for one app root. + * + * The record lets a second interactive `eve dev` attach to the same server. + * It is not a lock: a stale or malformed record simply causes the caller to + * start a new server and overwrite it once that server is ready. + */ +export class DevelopmentServerState { + readonly appRoot: string; + readonly #stateDir: string; + readonly #statePath: string; + + constructor(project: { readonly appRoot: string }) { + this.appRoot = project.appRoot; + this.#stateDir = join(this.appRoot, ".eve"); + this.#statePath = join(this.#stateDir, STATE_FILE_NAME); + } + + /** Returns the recorded URL, if the record exists and is valid. */ + async read(): Promise { + let raw: string; + + try { + raw = await readFile(this.#statePath, "utf8"); + } catch (error) { + if (isErrnoException(error, "ENOENT")) { + return undefined; + } + throw error; + } + + try { + const parsed = developmentServerStateSchema.safeParse(JSON.parse(raw)); + return parsed.success ? parsed.data.url : undefined; + } catch { + return undefined; + } + } + + /** Records a server after it is ready to accept clients. */ + async write(url: string): Promise { + const state = developmentServerStateSchema.parse({ url }); + await mkdir(this.#stateDir, { recursive: true }); + await writeFile(this.#statePath, `${JSON.stringify(state)}\n`, "utf8"); + } + + /** Clears the record after the listening server has stopped. */ + async remove(): Promise { + await rm(this.#statePath, { force: true }); + } +} + +function isErrnoException(error: unknown, code: string): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error && error.code === code; +} diff --git a/packages/eve/src/internal/nitro/host/start-development-server.test.ts b/packages/eve/src/internal/nitro/host/start-development-server.test.ts index 409a8a7f7..e0f4dddb4 100644 --- a/packages/eve/src/internal/nitro/host/start-development-server.test.ts +++ b/packages/eve/src/internal/nitro/host/start-development-server.test.ts @@ -5,7 +5,16 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + DevelopmentServerHandle, + DevelopmentServerOptions, +} from "#internal/nitro/host/types.js"; + const mocks = vi.hoisted(() => { + const fsControl: { + stateReadError?: Error; + stateWriteError?: Error; + } = {}; const authoredSourceWatcher = { close: vi.fn(async () => undefined), flush: vi.fn(async () => undefined), @@ -40,13 +49,22 @@ const mocks = vi.hoisted(() => { createApplicationNitro: vi.fn(async () => nitro), createDevServer: vi.fn(() => devServer), devServer, + fetch: vi.fn(async () => new Response(null, { status: 200 })), files, + fsControl, listenerServer, mkdir: vi.fn(async () => undefined), nitro, prepareApplicationHost: vi.fn(async () => ({ appRoot: "/tmp/eve-test" })), prepareNitro: vi.fn(async () => undefined), readFile: vi.fn(async (path: string) => { + if ( + path.endsWith("/.eve/dev-server-state.v1.json") && + fsControl.stateReadError !== undefined + ) { + throw fsControl.stateReadError; + } + const value = files.get(path); if (value === undefined) { @@ -58,15 +76,15 @@ const mocks = vi.hoisted(() => { rm: vi.fn(async (path: string) => { files.delete(path); }), - stat: vi.fn(async (path: string) => { - if (!files.has(path)) { - throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); - } - }), startDevelopmentSandboxPrewarmInBackground: vi.fn(() => undefined), pruneLocalSandboxTemplatesInBackground: vi.fn(() => undefined), stopDevelopmentSandboxResources: vi.fn(async () => undefined), pruneDevelopmentRuntimeArtifactsSnapshotsInBackground: vi.fn(() => undefined), + resolveDiscoveryProject: vi.fn(async () => ({ + agentRoot: "/tmp/eve-test/agent", + appRoot: "/tmp/eve-test", + layout: "nested" as const, + })), resolveNitroCompiledArtifactsSource: vi.fn(() => ({ appRoot: "/tmp/eve-test/.eve/dev-runtime-test", kind: "disk" as const, @@ -74,6 +92,12 @@ const mocks = vi.hoisted(() => { })), startAuthoredSourceWatcher: vi.fn(async () => authoredSourceWatcher), writeFile: vi.fn(async (path: string, value: string) => { + if ( + path.endsWith("/.eve/dev-server-state.v1.json") && + fsControl.stateWriteError !== undefined + ) { + throw fsControl.stateWriteError; + } files.set(path, value); }), }; @@ -83,7 +107,6 @@ vi.mock("node:fs/promises", () => ({ mkdir: mocks.mkdir, readFile: mocks.readFile, rm: mocks.rm, - stat: mocks.stat, writeFile: mocks.writeFile, })); @@ -105,6 +128,10 @@ vi.mock("./prepare-application-host.js", () => ({ prepareApplicationHost: mocks.prepareApplicationHost, })); +vi.mock("#discover/project.js", () => ({ + resolveDiscoveryProject: mocks.resolveDiscoveryProject, +})); + vi.mock("#internal/nitro/routes/runtime-artifacts.js", () => ({ resolveNitroCompiledArtifactsSource: mocks.resolveNitroCompiledArtifactsSource, })); @@ -158,15 +185,77 @@ function createSocket(): Socket { return socket; } -const developmentProcessIdPath = join("/tmp/eve-test", ".eve", "dev-process.pid"); -const developmentServerMetadataPath = join("/tmp/eve-test", ".eve", "dev-server.json"); +const developmentServerStatePath = join("/tmp/eve-test", ".eve", "dev-server-state.v1.json"); + +function readStateRecord( + path: string = developmentServerStatePath, +): Record | undefined { + const raw = mocks.files.get(path); + return raw === undefined ? undefined : (JSON.parse(raw) as Record); +} + +function seedStateRecord( + record: Record, + path: string = developmentServerStatePath, +): void { + mocks.files.set(path, `${JSON.stringify(record)}\n`); +} + +function createDeferred(): { + readonly promise: Promise; + reject(error: unknown): void; + resolve(value: T): void; +} { + let resolvePromise: ((value: T) => void) | undefined; + let rejectPromise: ((reason?: unknown) => void) | undefined; + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + + return { + promise, + reject(error) { + if (rejectPromise === undefined) { + throw new Error("Deferred promise was not initialized."); + } + rejectPromise(error); + }, + resolve(value) { + if (resolvePromise === undefined) { + throw new Error("Deferred promise was not initialized."); + } + resolvePromise(value); + }, + }; +} + +/** The owned-server shape the suite asserted against before `close()` moved onto `DevelopmentServer`. */ +type StartedTestServer = DevelopmentServerHandle & { close(): Promise }; + +/** + * Adapts `createDevelopmentServer().start()` back into the handle-plus-`close()` + * shape these tests assert against, so each call exercises the real factory, + * `start()`, and `close()` while keeping the call sites terse. + */ +async function loadStartDevelopmentServer(): Promise< + (rootDir: string, options?: DevelopmentServerOptions) => Promise +> { + const { createDevelopmentServer } = + await import("#internal/nitro/host/start-development-server.js"); + + return async (rootDir, options) => { + const server = createDevelopmentServer(rootDir, options); + const handle = await server.start(); + return Object.assign({ ...handle }, { close: () => server.close() }); + }; +} async function startServer(): Promise<{ close(): Promise; url: string; }> { - const { startDevelopmentServer } = - await import("#internal/nitro/host/start-development-server.js"); + const startDevelopmentServer = await loadStartDevelopmentServer(); return await startDevelopmentServer("/tmp/eve-test"); } @@ -198,9 +287,17 @@ describe("normalizeDevelopmentServerClientUrl", () => { }); }); -describe("startDevelopmentServer", () => { +describe("createDevelopmentServer", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.fetch.mockResolvedValue(new Response(null, { status: 200 })); + mocks.fsControl.stateReadError = undefined; + mocks.fsControl.stateWriteError = undefined; + mocks.authoredSourceWatcher.close.mockResolvedValue(undefined); + mocks.devServer.close.mockResolvedValue(undefined); + mocks.nitro.close.mockResolvedValue(undefined); + mocks.stopDevelopmentSandboxResources.mockResolvedValue(undefined); + vi.stubGlobal("fetch", mocks.fetch); delete process.env.WORKFLOW_LOCAL_BASE_URL; delete process.env.PORT; delete process.env.EVE_DEVELOPMENT_SANDBOX_RUN_ID; @@ -226,10 +323,11 @@ describe("startDevelopmentServer", () => { delete process.env.PORT; delete process.env.EVE_DEVELOPMENT_SANDBOX_RUN_ID; mocks.files.clear(); + vi.unstubAllGlobals(); }); it("pins local workflow queue callbacks to the active dev server URL", async () => { - const { startDevelopmentServer } = await import("./start-development-server.js"); + const startDevelopmentServer = await loadStartDevelopmentServer(); Object.assign(mocks.listenerServer, { url: "http://127.0.0.1:42123/", }); @@ -264,8 +362,8 @@ describe("startDevelopmentServer", () => { expect(process.env.EVE_DEVELOPMENT_SANDBOX_RUN_ID).toBeUndefined(); }); - it("uses eve's default port when no port is requested", async () => { - const { startDevelopmentServer } = await import("./start-development-server.js"); + it("uses Eve's default port when no port is requested", async () => { + const startDevelopmentServer = await loadStartDevelopmentServer(); Object.assign(mocks.nitro.options.devServer, { port: 3000, }); @@ -282,7 +380,7 @@ describe("startDevelopmentServer", () => { }); it("normalizes wildcard IPv6 listener URLs before exposing them to the REPL or workflow", async () => { - const { startDevelopmentServer } = await import("./start-development-server.js"); + const startDevelopmentServer = await loadStartDevelopmentServer(); Object.assign(mocks.listenerServer, { url: "http://[::]:2000/", }); @@ -297,7 +395,7 @@ describe("startDevelopmentServer", () => { }); it("retries the next port on IPv4 loopback when the default port is occupied", async () => { - const { startDevelopmentServer } = await import("./start-development-server.js"); + const startDevelopmentServer = await loadStartDevelopmentServer(); const addressInUseError = Object.assign(new Error("Address already in use"), { code: "EADDRINUSE", }); @@ -328,80 +426,275 @@ describe("startDevelopmentServer", () => { await server.close(); }); - it("writes the active dev process id and removes it on close", async () => { - const { startDevelopmentServer } = await import("./start-development-server.js"); + it("records the active dev server URL and removes the state on close", async () => { + const startDevelopmentServer = await loadStartDevelopmentServer(); const server = await startDevelopmentServer("/tmp/eve-test"); - expect(mocks.files.get(developmentProcessIdPath)).toBe(`${process.pid}\n`); - expect(JSON.parse(mocks.files.get(developmentServerMetadataPath) ?? "{}")).toMatchObject({ - pid: process.pid, - url: "http://localhost:2000/", - }); + const record = readStateRecord(); + expect(record).toEqual({ url: "http://localhost:2000/" }); await server.close(); - expect(mocks.files.has(developmentProcessIdPath)).toBe(false); - expect(mocks.files.has(developmentServerMetadataPath)).toBe(false); + expect(mocks.files.has(developmentServerStatePath)).toBe(false); }); - it("refuses to start when the agent already has a running dev process", async () => { - const { startDevelopmentServer } = await import("./start-development-server.js"); - mocks.files.set(developmentProcessIdPath, `${process.pid}\n`); + it("attempts every cleanup step when the authored-source watcher fails to close", async () => { + const startDevelopmentServer = await loadStartDevelopmentServer(); + const server = await startDevelopmentServer("/tmp/eve-test"); + mocks.authoredSourceWatcher.close.mockRejectedValueOnce(new Error("watcher close failed")); + + await expect(server.close()).rejects.toThrow("watcher close failed"); + + expect(mocks.devServer.close).toHaveBeenCalledOnce(); + expect(mocks.nitro.close).toHaveBeenCalledOnce(); + expect(mocks.stopDevelopmentSandboxResources).toHaveBeenCalledOnce(); + expect(readStateRecord()).toBeUndefined(); + }); + + it("keeps the state record when the listener fails to close", async () => { + const startDevelopmentServer = await loadStartDevelopmentServer(); + const server = await startDevelopmentServer("/tmp/eve-test"); + mocks.devServer.close.mockRejectedValueOnce(new Error("listener close failed")); + + await expect(server.close()).rejects.toThrow("listener close failed"); + + expect(mocks.nitro.close).toHaveBeenCalledOnce(); + expect(mocks.stopDevelopmentSandboxResources).toHaveBeenCalledOnce(); + expect(readStateRecord()).toEqual({ url: "http://localhost:2000/" }); + }); + + it("closes the server when its state record cannot be written", async () => { + const startDevelopmentServer = await loadStartDevelopmentServer(); + mocks.fsControl.stateWriteError = Object.assign(new Error("disk full"), { code: "ENOSPC" }); + + await expect(startDevelopmentServer("/tmp/eve-test")).rejects.toThrow("disk full"); + + expect(mocks.devServer.close).toHaveBeenCalledOnce(); + expect(mocks.nitro.close).toHaveBeenCalledOnce(); + expect(readStateRecord()).toBeUndefined(); + }); + + it("does not start when the state record cannot be read", async () => { + const startDevelopmentServer = await loadStartDevelopmentServer(); + mocks.fsControl.stateReadError = Object.assign(new Error("permission denied"), { + code: "EACCES", + }); + + await expect(startDevelopmentServer("/tmp/eve-test")).rejects.toThrow("permission denied"); - await expect(startDevelopmentServer("/tmp/eve-test")).rejects.toThrow( - [ - `A dev server is already running for this eve agent (pid ${process.pid}).`, - "To connect to the existing instance, run: pnpm exec eve dev http://localhost:PORT", - `To stop it, run: ${ - process.platform === "win32" ? "taskkill /PID" : "kill" - } ${process.pid}`, - ].join("\n"), - ); expect(mocks.createApplicationNitro).not.toHaveBeenCalled(); }); - it("prints a copyable connect command with the detected package manager and server URL", async () => { - const { startDevelopmentServer } = await import("./start-development-server.js"); - mocks.files.set( - join("/tmp/eve-test", "package.json"), - JSON.stringify({ packageManager: "npm@10.0.0" }), - ); - mocks.files.set(developmentProcessIdPath, `${process.pid}\n`); - mocks.files.set( - developmentServerMetadataPath, - JSON.stringify({ - pid: process.pid, - updatedAt: "2026-06-17T00:00:00.000Z", - url: "http://127.0.0.1:4321/", - }), - ); + it("reports a healthy recorded server when starting another server", async () => { + const startDevelopmentServer = await loadStartDevelopmentServer(); + seedStateRecord({ url: "http://localhost:2000/" }); await expect(startDevelopmentServer("/tmp/eve-test")).rejects.toThrow( [ - `A dev server is already running for this eve agent (pid ${process.pid}).`, - "To connect to the existing instance, run: npm exec -- eve dev http://127.0.0.1:4321/", - `To stop it, run: ${ - process.platform === "win32" ? "taskkill /PID" : "kill" - } ${process.pid}`, + "A dev server is already running for this eve agent.", + "To connect to the existing instance, run: pnpm exec eve dev http://localhost:2000/", ].join("\n"), ); expect(mocks.createApplicationNitro).not.toHaveBeenCalled(); }); - it("overwrites a stale dev process id", async () => { - const { startDevelopmentServer } = await import("./start-development-server.js"); - mocks.files.set(developmentProcessIdPath, "999999999\n"); + it("reuses the active server recorded for the same app root when requested", async () => { + const startDevelopmentServer = await loadStartDevelopmentServer(); + + const owner = await startDevelopmentServer("/tmp/eve-test"); + const ownerSandboxRunId = process.env.EVE_DEVELOPMENT_SANDBOX_RUN_ID; + // A real attaching TUI is a separate process and does not inherit the + // owner's internally installed listener port. + delete process.env.PORT; + const attached = await startDevelopmentServer("/tmp/eve-test", { + existing: "attach-if-unconfigured", + }); + + expect(attached.kind).toBe("existing"); + expect(attached.url).toBe(owner.url); + expect(mocks.createApplicationNitro).toHaveBeenCalledOnce(); + expect(mocks.fetch).toHaveBeenCalledWith("http://localhost:2000/eve/v1/health", { + redirect: "error", + signal: expect.any(AbortSignal), + }); + expect(process.env.EVE_DEVELOPMENT_SANDBOX_RUN_ID).toBe(ownerSandboxRunId); + + expect(mocks.devServer.close).not.toHaveBeenCalled(); + expect(process.env.EVE_DEVELOPMENT_SANDBOX_RUN_ID).toBe(ownerSandboxRunId); + expect(readStateRecord()).toEqual({ url: "http://localhost:2000/" }); + + await owner.close(); + expect(process.env.EVE_DEVELOPMENT_SANDBOX_RUN_ID).toBeUndefined(); + }); + + it("close() tears nothing down when the instance attached to an existing owner", async () => { + const { createDevelopmentServer } = await import("./start-development-server.js"); + + const owner = createDevelopmentServer("/tmp/eve-test"); + await owner.start(); + // A real attaching TUI is a separate process and does not inherit the + // owner's internally installed listener port. + delete process.env.PORT; + + const attaching = createDevelopmentServer("/tmp/eve-test", { + existing: "attach-if-unconfigured", + }); + const attached = await attaching.start(); + expect(attached.kind).toBe("existing"); + + mocks.devServer.close.mockClear(); + await attaching.close(); + // The attaching instance owns nothing, so close() is a no-op: it neither + // closes the listener nor disturbs the owner's published state. + expect(mocks.devServer.close).not.toHaveBeenCalled(); + expect(readStateRecord()).toEqual({ url: "http://localhost:2000/" }); + + await owner.close(); + }); + + it("waits for a pending start before closing an owned server", async () => { + const { createDevelopmentServer } = await import("./start-development-server.js"); + const project = createDeferred<{ + readonly agentRoot: string; + readonly appRoot: string; + readonly layout: "nested"; + }>(); + mocks.resolveDiscoveryProject.mockReturnValueOnce(project.promise); + + const server = createDevelopmentServer("/tmp/eve-test"); + const starting = server.start(); + await vi.waitFor(() => expect(mocks.resolveDiscoveryProject).toHaveBeenCalledOnce()); + const closing = server.close(); + + project.resolve({ + agentRoot: "/tmp/eve-test/agent", + appRoot: "/tmp/eve-test", + layout: "nested", + }); + + await starting; + await closing; + + expect(mocks.devServer.close).toHaveBeenCalledOnce(); + expect(readStateRecord()).toBeUndefined(); + }); + + it("waits for a failed start without rethrowing from close", async () => { + const { createDevelopmentServer } = await import("./start-development-server.js"); + const project = createDeferred<{ + readonly agentRoot: string; + readonly appRoot: string; + readonly layout: "nested"; + }>(); + mocks.resolveDiscoveryProject.mockReturnValueOnce(project.promise); + + const server = createDevelopmentServer("/tmp/eve-test"); + const starting = server.start(); + await vi.waitFor(() => expect(mocks.resolveDiscoveryProject).toHaveBeenCalledOnce()); + const closing = server.close(); + + project.reject(new Error("discovery failed")); + + await expect(starting).rejects.toThrow("discovery failed"); + await expect(closing).resolves.toBeUndefined(); + expect(mocks.devServer.close).not.toHaveBeenCalled(); + }); + + it("does not attach when PORT explicitly configures the endpoint", async () => { + const startDevelopmentServer = await loadStartDevelopmentServer(); + process.env.PORT = "2000"; + seedStateRecord({ url: "http://localhost:2000/" }); + + await expect( + startDevelopmentServer("/tmp/eve-test", { existing: "attach-if-unconfigured" }), + ).rejects.toThrow("A dev server is already running for this eve agent."); + expect(mocks.createApplicationNitro).not.toHaveBeenCalled(); + expect(mocks.fetch).toHaveBeenCalledOnce(); + }); + + it("rejects reuse when the requested environment port conflicts", async () => { + const startDevelopmentServer = await loadStartDevelopmentServer(); + process.env.PORT = "2001"; + seedStateRecord({ url: "http://localhost:2000/" }); + + await expect( + startDevelopmentServer("/tmp/eve-test", { existing: "attach-if-unconfigured" }), + ).rejects.toThrow("A dev server is already running for this eve agent."); + expect(mocks.createApplicationNitro).not.toHaveBeenCalled(); + expect(mocks.fetch).toHaveBeenCalledOnce(); + }); + + it("overwrites an unhealthy state record", async () => { + const startDevelopmentServer = await loadStartDevelopmentServer(); + mocks.fetch.mockResolvedValue(new Response(null, { status: 503 })); + seedStateRecord({ url: "http://localhost:2000/" }); + + const server = await startDevelopmentServer("/tmp/eve-test", { + existing: "attach-if-unconfigured", + }); + + expect(mocks.fetch).toHaveBeenCalledWith("http://localhost:2000/eve/v1/health", { + redirect: "error", + signal: expect.any(AbortSignal), + }); + expect(mocks.fetch).toHaveBeenCalledOnce(); + expect(mocks.createApplicationNitro).toHaveBeenCalledOnce(); + expect(readStateRecord()).toEqual({ url: "http://localhost:2000/" }); + + await server.close(); + }); + + it("does not probe a non-loopback URL from persisted state", async () => { + const startDevelopmentServer = await loadStartDevelopmentServer(); + seedStateRecord({ url: "http://192.168.1.20:2000/" }); + + const server = await startDevelopmentServer("/tmp/eve-test", { + existing: "attach-if-unconfigured", + }); + + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.createApplicationNitro).toHaveBeenCalledOnce(); + + await server.close(); + }); + + it("does not reuse a server recorded under another app root", async () => { + const startDevelopmentServer = await loadStartDevelopmentServer(); + const otherAppRoot = "/tmp/other-eve-test"; + + seedStateRecord( + { url: "http://127.0.0.1:2999/" }, + join(otherAppRoot, ".eve", "dev-server-state.v1.json"), + ); + + const server = await startDevelopmentServer("/tmp/eve-test", { + existing: "attach-if-unconfigured", + }); + + expect(server.url).toBe("http://localhost:2000/"); + expect(mocks.createApplicationNitro).toHaveBeenCalledOnce(); + + if (server.kind !== "started") { + throw new Error("Expected to start the server for the requested app root."); + } + await server.close(); + }); + + it("overwrites a stale dev server record", async () => { + const startDevelopmentServer = await loadStartDevelopmentServer(); + mocks.fetch.mockResolvedValue(new Response(null, { status: 503 })); + seedStateRecord({ url: "http://localhost:2000/" }); const server = await startDevelopmentServer("/tmp/eve-test"); - expect(mocks.files.get(developmentProcessIdPath)).toBe(`${process.pid}\n`); + expect(readStateRecord()).toEqual({ url: "http://localhost:2000/" }); await server.close(); }); it("normalizes wildcard IPv4 listener URLs before exposing them to the REPL or workflow", async () => { - const { startDevelopmentServer } = await import("./start-development-server.js"); + const startDevelopmentServer = await loadStartDevelopmentServer(); Object.assign(mocks.listenerServer, { url: "http://0.0.0.0:2000/", }); @@ -415,7 +708,7 @@ describe("startDevelopmentServer", () => { }); it("honors the PORT environment variable when no port option is provided", async () => { - const { startDevelopmentServer } = await import("./start-development-server.js"); + const startDevelopmentServer = await loadStartDevelopmentServer(); process.env.PORT = "4321"; Object.assign(mocks.listenerServer, { url: "http://127.0.0.1:4321/", @@ -429,7 +722,7 @@ describe("startDevelopmentServer", () => { }); it("prefers the explicit port option over the PORT environment variable", async () => { - const { startDevelopmentServer } = await import("./start-development-server.js"); + const startDevelopmentServer = await loadStartDevelopmentServer(); process.env.PORT = "4321"; const server = await startDevelopmentServer("/tmp/eve-test", { port: 5000 }); @@ -440,7 +733,7 @@ describe("startDevelopmentServer", () => { }); it("rejects when the PORT environment variable is not a valid port", async () => { - const { startDevelopmentServer } = await import("./start-development-server.js"); + const startDevelopmentServer = await loadStartDevelopmentServer(); process.env.PORT = "not-a-port"; await expect(startDevelopmentServer("/tmp/eve-test")).rejects.toThrow( diff --git a/packages/eve/src/internal/nitro/host/start-development-server.ts b/packages/eve/src/internal/nitro/host/start-development-server.ts index 06c0ee49a..29f6ae41d 100644 --- a/packages/eve/src/internal/nitro/host/start-development-server.ts +++ b/packages/eve/src/internal/nitro/host/start-development-server.ts @@ -1,7 +1,5 @@ -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import type { IncomingMessage } from "node:http"; import type { Socket } from "node:net"; -import { join } from "node:path"; import { EVE_DEV_ENV_FLAG } from "#internal/application/optional-package-install.js"; @@ -12,6 +10,11 @@ import { createApplicationNitro } from "#internal/nitro/host/create-application- import { createNitroArtifactsConfig } from "#internal/nitro/host/artifacts-config.js"; import type { AuthoredSourceWatcherHandle } from "#internal/nitro/host/dev-authored-source-watcher.js"; import { prepareApplicationHost } from "#internal/nitro/host/prepare-application-host.js"; +import { resolveDiscoveryProject } from "#discover/project.js"; +import { DevelopmentServerState } from "#internal/nitro/host/dev-server-state.js"; +import { toErrorMessage } from "#shared/errors.js"; +import { isEveServerHealthy } from "#shared/eve-server-health.js"; +import { isLoopbackServerUrl } from "#shared/network-address.js"; import { resolveNitroCompiledArtifactsSource } from "#internal/nitro/routes/runtime-artifacts.js"; import { pruneLocalSandboxTemplatesInBackground, @@ -25,8 +28,10 @@ import { getInitializedDevelopmentSandboxBackendNames, } from "#execution/sandbox/development-run.js"; import type { + DevelopmentServer, DevelopmentServerHandle, DevelopmentServerOptions, + StartedDevelopmentServer, } from "#internal/nitro/host/types.js"; import { loadDevelopmentEnvironmentFiles } from "#cli/dev/environment.js"; import { pruneDevelopmentRuntimeArtifactsSnapshotsInBackground } from "#internal/nitro/dev-runtime-artifacts.js"; @@ -41,21 +46,8 @@ import { devBootPhase } from "#internal/dev-boot-progress.js"; const MAX_ALLOWED_DEVELOPMENT_SERVER_PORT = 65_535; const WORKFLOW_LOCAL_BASE_URL_ENV = "WORKFLOW_LOCAL_BASE_URL"; const PORT_ENV = "PORT"; -const DEVELOPMENT_PROCESS_ID_FILE = "dev-process.pid"; -const DEVELOPMENT_SERVER_METADATA_FILE = "dev-server.json"; const DEFAULT_DEVELOPMENT_SERVER_HOST = "127.0.0.1"; const IPV6_LOOPBACK_HOSTNAME = "[::1]"; -const DEVELOPMENT_SERVER_URL_PLACEHOLDER = "http://localhost:PORT"; - -interface DevelopmentServerMetadata { - readonly processId: number; - readonly url: string; -} - -interface ActiveDevelopmentProcess { - readonly processId: number; - readonly url?: string; -} /** * Hostnames Nitro/srvx surface when listening on an IPv6 wildcard interface. @@ -125,118 +117,6 @@ function readEnvironmentPort(): number | undefined { return parsed; } -function resolveDevelopmentProcessIdPath(appRoot: string): string { - return join(appRoot, ".eve", DEVELOPMENT_PROCESS_ID_FILE); -} - -function resolveDevelopmentServerMetadataPath(appRoot: string): string { - return join(appRoot, ".eve", DEVELOPMENT_SERVER_METADATA_FILE); -} - -function parseProcessId(value: string): number | undefined { - const trimmed = value.trim(); - - if (!/^\d+$/.test(trimmed)) { - return undefined; - } - - const processId = Number(trimmed); - return Number.isSafeInteger(processId) && processId > 0 ? processId : undefined; -} - -function isProcessRunning(processId: number): boolean { - try { - process.kill(processId, 0); - return true; - } catch (error) { - return ( - error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "EPERM" - ); - } -} - -function formatKillCommand(processId: number): string { - if (process.platform === "win32") { - return `taskkill /PID ${processId}`; - } - - return `kill ${processId}`; -} - -function parseDevelopmentServerMetadata(value: string): DevelopmentServerMetadata | undefined { - let parsed: unknown; - - try { - parsed = JSON.parse(value); - } catch { - return undefined; - } - - if ( - typeof parsed !== "object" || - parsed === null || - !("pid" in parsed) || - typeof parsed.pid !== "number" || - !Number.isSafeInteger(parsed.pid) || - parsed.pid <= 0 || - !("url" in parsed) || - typeof parsed.url !== "string" - ) { - return undefined; - } - - const url = normalizeDevelopmentServerClientUrl(parsed.url); - - try { - const parsedUrl = new URL(url); - if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { - return undefined; - } - } catch { - return undefined; - } - - return { - processId: parsed.pid, - url, - }; -} - -async function readDevelopmentServerMetadata( - appRoot: string, -): Promise { - try { - return parseDevelopmentServerMetadata( - await readFile(resolveDevelopmentServerMetadataPath(appRoot), "utf8"), - ); - } catch { - return undefined; - } -} - -async function readActiveDevelopmentProcess( - appRoot: string, -): Promise { - let processId: number | undefined; - - try { - processId = parseProcessId(await readFile(resolveDevelopmentProcessIdPath(appRoot), "utf8")); - } catch { - return undefined; - } - - if (processId === undefined || !isProcessRunning(processId)) { - return undefined; - } - - const metadata = await readDevelopmentServerMetadata(appRoot); - - return { - processId, - url: metadata?.processId === processId ? metadata.url : undefined, - }; -} - async function detectDevelopmentCommandPackageManager( appRoot: string, ): Promise { @@ -255,64 +135,19 @@ async function formatDevelopmentServerConnectCommand( return [packageManager, ...eveDevArguments(packageManager), serverUrl].join(" "); } -async function writeDevelopmentServerMetadata(appRoot: string, serverUrl: string): Promise { - await writeFile( - resolveDevelopmentServerMetadataPath(appRoot), - `${JSON.stringify( - { - pid: process.pid, - updatedAt: new Date().toISOString(), - url: normalizeDevelopmentServerClientUrl(serverUrl), - }, - null, - 2, - )}\n`, - "utf8", +async function createDevelopmentServerAlreadyRunningError( + appRoot: string, + serverUrl: string, +): Promise { + const connectCommand = await formatDevelopmentServerConnectCommand(appRoot, serverUrl); + return new Error( + [ + "A dev server is already running for this eve agent.", + `To connect to the existing instance, run: ${connectCommand}`, + ].join("\n"), ); } -async function writeDevelopmentProcessId(appRoot: string): Promise<() => Promise> { - const processIdPath = resolveDevelopmentProcessIdPath(appRoot); - const metadataPath = resolveDevelopmentServerMetadataPath(appRoot); - const activeProcess = await readActiveDevelopmentProcess(appRoot); - - if (activeProcess !== undefined) { - const connectUrl = activeProcess.url ?? DEVELOPMENT_SERVER_URL_PLACEHOLDER; - const connectCommand = await formatDevelopmentServerConnectCommand(appRoot, connectUrl); - throw new Error( - [ - `A dev server is already running for this eve agent (pid ${activeProcess.processId}).`, - `To connect to the existing instance, run: ${connectCommand}`, - `To stop it, run: ${formatKillCommand(activeProcess.processId)}`, - ].join("\n"), - ); - } - - await mkdir(join(appRoot, ".eve"), { recursive: true }); - await writeFile(processIdPath, `${process.pid}\n`, "utf8"); - - return async () => { - let currentProcessId: number | undefined; - - try { - currentProcessId = parseProcessId(await readFile(processIdPath, "utf8")); - } catch { - return; - } - - if (currentProcessId === process.pid) { - await rm(processIdPath, { force: true }); - await rm(metadataPath, { force: true }); - return; - } - - const metadata = await readDevelopmentServerMetadata(appRoot); - if (metadata?.processId === process.pid) { - await rm(metadataPath, { force: true }); - } - }; -} - function resolveDevelopmentServerPorts(input: { readonly port: number | string | undefined; readonly retryOnAddressInUse: boolean; @@ -413,6 +248,72 @@ function guardDevelopmentServerWebSocketUpgrades( devServer.upgrade = guardedUpgrade; } +async function closeDevelopmentServerResources(input: { + readonly authoredSourceWatcher: AuthoredSourceWatcherHandle | undefined; + readonly devServer: NitroDevelopmentServer | undefined; + readonly developmentSandboxRunId: string; + readonly nitro: Nitro | undefined; +}): Promise<{ readonly errors: readonly unknown[]; readonly listenerClosed: boolean }> { + const errors: unknown[] = []; + const attempt = async (operation: () => Promise): Promise => { + try { + await operation(); + return true; + } catch (error) { + errors.push(error); + return false; + } + }; + + const authoredSourceWatcher = input.authoredSourceWatcher; + if (authoredSourceWatcher !== undefined) { + await attempt(() => authoredSourceWatcher.close()); + } + const devServer = input.devServer; + const listenerClosed = devServer === undefined ? true : await attempt(() => devServer.close()); + const nitro = input.nitro; + if (nitro !== undefined) { + await attempt(() => nitro.close()); + } + await attempt(() => + stopDevelopmentSandboxResources({ + backendNames: getInitializedDevelopmentSandboxBackendNames(input.developmentSandboxRunId), + devRunId: input.developmentSandboxRunId, + log: (message) => console.warn(`[eve:dev] ${message}`), + }), + ); + + return { errors, listenerClosed }; +} + +function createDevelopmentServerCleanupError(errors: readonly unknown[]): Error | undefined { + if (errors.length === 0) { + return undefined; + } + + if (errors.length === 1) { + const error = errors[0]; + return error instanceof Error + ? error + : new Error(`Failed to close the development server: ${toErrorMessage(error)}`, { + cause: error, + }); + } + + return new AggregateError(errors, "Multiple development-server resources failed to close."); +} + +function createDevelopmentServerStartupCleanupError( + startupError: unknown, + cleanupErrors: readonly unknown[], +): AggregateError { + return new AggregateError( + [startupError, ...cleanupErrors], + `${toErrorMessage(startupError)} Cleanup also failed.`, + { cause: startupError }, + ); +} + async function listenForDevelopmentServer(input: { readonly devServer: NitroDevelopmentServer; readonly host?: string; @@ -457,24 +358,45 @@ async function listenForDevelopmentServer(input: { ); } -/** - * Starts the development Nitro server for an eve application. - * - * Authored schedules are never registered with Nitro's cron scheduler in - * dev mode. To fire one authored schedule on demand, `POST` the dev-only - * `/eve/v1/dev/schedules/:scheduleId` route — the handler returns - * `{ scheduleId, sessionIds }` so callers can subscribe to the existing - * per-session stream route. - */ -export async function startDevelopmentServer( +interface DevelopmentServerStartResult { + readonly handle: DevelopmentServerHandle; + /** Teardown for a server this process owns; undefined when attached to an existing owner. */ + readonly close: (() => Promise) | undefined; +} + +async function startNitroDevelopmentServer( rootDir: string, - options: DevelopmentServerOptions = {}, -): Promise { + options: DevelopmentServerOptions, +): Promise { // Marks this process tree as an `eve dev` session so runtime features // that must never run in production (for example auto-installing // optional sandbox engine packages) can gate on it. process.env[EVE_DEV_ENV_FLAG] ??= "1"; - loadDevelopmentEnvironmentFiles(rootDir); + + const project = await resolveDiscoveryProject(rootDir); + loadDevelopmentEnvironmentFiles(project.appRoot); + + const environmentPort = readEnvironmentPort(); + const requestedPort = options.port ?? environmentPort; + const hasExplicitEndpoint = + options.host !== undefined || options.port !== undefined || environmentPort !== undefined; + const state = new DevelopmentServerState(project); + const existingServerUrl = await state.read(); + + if ( + existingServerUrl !== undefined && + isLoopbackServerUrl(existingServerUrl) && + (await isEveServerHealthy(existingServerUrl)) + ) { + if (options.existing === "attach-if-unconfigured" && !hasExplicitEndpoint) { + return { + handle: { kind: "existing", appRoot: project.appRoot, url: existingServerUrl }, + close: undefined, + }; + } + throw await createDevelopmentServerAlreadyRunningError(project.appRoot, existingServerUrl); + } + const previousDevelopmentSandboxRunId = process.env[EVE_DEVELOPMENT_SANDBOX_RUN_ID_ENV]; const developmentSandboxRunId = createDevelopmentSandboxRunId(); process.env[EVE_DEVELOPMENT_SANDBOX_RUN_ID_ENV] = developmentSandboxRunId; @@ -482,15 +404,13 @@ export async function startDevelopmentServer( let devServer: NitroDevelopmentServer | undefined; let restoreWorkflowLocalQueueEnvironment: (() => void) | undefined; let authoredSourceWatcher: AuthoredSourceWatcherHandle | undefined; - let removeDevelopmentProcessId: (() => Promise) | undefined; try { const preparedHost = await devBootPhase( "compiling agent", - () => prepareApplicationHost(rootDir, { dev: true }), + () => prepareApplicationHost(project.appRoot, { dev: true }), options.onBootProgress, ); - removeDevelopmentProcessId = await writeDevelopmentProcessId(preparedHost.appRoot); pruneDevelopmentRuntimeArtifactsSnapshotsInBackground(preparedHost.appRoot); const compiledArtifactsSource = resolveNitroCompiledArtifactsSource( createNitroArtifactsConfig({ @@ -514,7 +434,6 @@ export async function startDevelopmentServer( guardDevelopmentServerWebSocketUpgrades(activeNitro, devServer); const hostname = options.host ?? activeNitro.options.devServer.hostname ?? DEFAULT_DEVELOPMENT_SERVER_HOST; - const requestedPort = options.port ?? readEnvironmentPort(); const retryOnAddressInUse = requestedPort === undefined; const server = await devBootPhase( "binding port", @@ -533,7 +452,6 @@ export async function startDevelopmentServer( } const serverUrl = normalizeDevelopmentServerClientUrl(server.url); - await writeDevelopmentServerMetadata(preparedHost.appRoot, serverUrl); restoreWorkflowLocalQueueEnvironment = installWorkflowLocalQueueEnvironment(serverUrl); await devBootPhase( "building dev bundle", @@ -549,10 +467,14 @@ export async function startDevelopmentServer( async () => { const { startAuthoredSourceWatcher } = await import("#internal/nitro/host/dev-authored-source-watcher.js"); - return startAuthoredSourceWatcher({ nitro: activeNitro, preparedHost }); + return startAuthoredSourceWatcher({ + nitro: activeNitro, + preparedHost, + }); }, options.onBootProgress, ); + await state.write(serverUrl); const restoreWorkflowLocalQueueEnvironmentOnClose = restoreWorkflowLocalQueueEnvironment; if (restoreWorkflowLocalQueueEnvironmentOnClose === undefined) { throw new Error("Workflow local queue environment was not initialized."); @@ -561,43 +483,108 @@ export async function startDevelopmentServer( const authoredSourceWatcherOnClose = authoredSourceWatcher; const devServerOnClose = devServer; const nitroOnClose = activeNitro; - return { - async close() { + let closePromise: Promise | undefined; + const close = (): Promise => { + closePromise ??= (async () => { + const cleanup = await closeDevelopmentServerResources({ + authoredSourceWatcher: authoredSourceWatcherOnClose, + devServer: devServerOnClose, + developmentSandboxRunId, + nitro: nitroOnClose, + }); + if (cleanup.listenerClosed) { + await state.remove().catch(() => {}); + } + try { - await authoredSourceWatcherOnClose.close(); - await devServerOnClose.close(); - await nitroOnClose.close(); - await stopDevelopmentSandboxResources({ - backendNames: getInitializedDevelopmentSandboxBackendNames(developmentSandboxRunId), - devRunId: developmentSandboxRunId, - log: (message) => console.warn(`[eve:dev] ${message}`), - }); + const cleanupError = createDevelopmentServerCleanupError(cleanup.errors); + if (cleanupError !== undefined) { + throw cleanupError; + } } finally { clearInitializedDevelopmentSandboxBackendNames(developmentSandboxRunId); - await removeDevelopmentProcessId?.(); restoreWorkflowLocalQueueEnvironmentOnClose(); restoreDevelopmentSandboxRunId(previousDevelopmentSandboxRunId); } - }, - url: serverUrl, + })(); + return closePromise; + }; + return { + handle: { kind: "started", appRoot: project.appRoot, url: serverUrl }, + close, }; } catch (error) { - await authoredSourceWatcher?.close().catch(() => {}); + const cleanup = await closeDevelopmentServerResources({ + authoredSourceWatcher, + devServer, + developmentSandboxRunId, + nitro, + }); + const cleanupErrors = [...cleanup.errors]; restoreWorkflowLocalQueueEnvironment?.(); - await devServer?.close().catch(() => {}); - await nitro?.close().catch(() => {}); - await stopDevelopmentSandboxResources({ - backendNames: getInitializedDevelopmentSandboxBackendNames(developmentSandboxRunId), - devRunId: developmentSandboxRunId, - log: (message) => console.warn(`[eve:dev] ${message}`), - }).catch(() => {}); clearInitializedDevelopmentSandboxBackendNames(developmentSandboxRunId); - await removeDevelopmentProcessId?.().catch(() => {}); + if (cleanup.listenerClosed) { + await state.remove().catch(() => {}); + } restoreDevelopmentSandboxRunId(previousDevelopmentSandboxRunId); + if (cleanupErrors.length > 0) { + throw createDevelopmentServerStartupCleanupError(error, cleanupErrors); + } throw error; } } +/** + * Creates a development server for an eve application. Call `start()` to boot an + * owned Nitro server or attach to a running owner, and `close()` to tear down a + * server this instance started. `close()` waits for an in-progress `start()`, + * resolves after failed-start cleanup, and is a no-op when it attached to an + * existing owner or was never started. + * + * Authored schedules are never registered with Nitro's cron scheduler in dev + * mode. To fire one authored schedule on demand, `POST` the dev-only + * `/eve/v1/dev/schedules/:scheduleId` route — the handler returns + * `{ scheduleId, sessionIds }` so callers can subscribe to the existing + * per-session stream route. + */ +export function createDevelopmentServer( + rootDir: string, + options?: DevelopmentServerOptions & { existing?: "reject" }, +): DevelopmentServer; +export function createDevelopmentServer( + rootDir: string, + options?: DevelopmentServerOptions, +): DevelopmentServer; +export function createDevelopmentServer( + rootDir: string, + options: DevelopmentServerOptions = {}, +): DevelopmentServer { + let startPromise: Promise | undefined; + let closeStartedServer: (() => Promise) | undefined; + + return { + start(): Promise { + if (startPromise !== undefined) { + throw new Error("DevelopmentServer.start() was already called."); + } + + startPromise = startNitroDevelopmentServer(rootDir, options).then(({ handle, close }) => { + closeStartedServer = close; + return handle; + }); + return startPromise; + }, + async close(): Promise { + if (startPromise === undefined) { + return; + } + + await startPromise.catch(() => undefined); + await closeStartedServer?.(); + }, + }; +} + function restoreDevelopmentSandboxRunId(previous: string | undefined): void { if (previous === undefined) { delete process.env[EVE_DEVELOPMENT_SANDBOX_RUN_ID_ENV]; diff --git a/packages/eve/src/internal/nitro/host/types.ts b/packages/eve/src/internal/nitro/host/types.ts index 96522fefb..238694636 100644 --- a/packages/eve/src/internal/nitro/host/types.ts +++ b/packages/eve/src/internal/nitro/host/types.ts @@ -9,15 +9,40 @@ import type { DevBootProgressReporter } from "#internal/dev-boot-progress.js"; */ export type NitroBuildSurface = "all" | "app" | "flow"; +/** Outcome of starting a Nitro development server the current process owns. */ +export interface StartedDevelopmentServer { + readonly kind: "started"; + readonly appRoot: string; + readonly url: string; +} + +/** A live development server owned by another process. */ +export interface ExistingDevelopmentServer { + readonly kind: "existing"; + readonly appRoot: string; + readonly url: string; +} + +/** Result of starting a development server for an app root. */ +export type DevelopmentServerHandle = StartedDevelopmentServer | ExistingDevelopmentServer; + /** - * Handle returned after starting one Nitro development server. + * Lifecycle for one in-process Nitro development server. + * + * `start()` either boots a server this process owns or attaches to a running + * owner; the {@link DevelopmentServerHandle} result discriminates which. + * `close()` waits for an in-progress `start()`, then tears down only a server + * this instance started. It resolves after startup cleanup when `start()` + * fails, and is a no-op when the instance attached to an existing owner or was + * never started. */ -export interface DevelopmentServerHandle { +export interface DevelopmentServer { + start(): Promise; close(): Promise; - url: string; } export interface DevelopmentServerOptions { + readonly existing?: "attach-if-unconfigured" | "reject"; readonly host?: string; readonly onBootProgress?: DevBootProgressReporter; readonly port?: number; diff --git a/packages/eve/src/shared/eve-server-health.test.ts b/packages/eve/src/shared/eve-server-health.test.ts new file mode 100644 index 000000000..02b144f69 --- /dev/null +++ b/packages/eve/src/shared/eve-server-health.test.ts @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { isEveServerHealthy } from "./eve-server-health.js"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("isEveServerHealthy", () => { + it("refuses health redirects", async () => { + const fetchMock = vi.fn(async (_url: string, options: RequestInit) => { + expect(options.redirect).toBe("error"); + return new Response(null, { status: 200 }); + }); + vi.stubGlobal("fetch", fetchMock); + + await expect(isEveServerHealthy("http://127.0.0.1:2000")).resolves.toBe(true); + expect(fetchMock).toHaveBeenCalledWith( + "http://127.0.0.1:2000/eve/v1/health", + expect.objectContaining({ redirect: "error", signal: expect.any(AbortSignal) }), + ); + }); +}); diff --git a/packages/eve/src/shared/eve-server-health.ts b/packages/eve/src/shared/eve-server-health.ts new file mode 100644 index 000000000..771ec350b --- /dev/null +++ b/packages/eve/src/shared/eve-server-health.ts @@ -0,0 +1,26 @@ +import { EVE_HEALTH_ROUTE_PATH } from "#protocol/routes.js"; + +const DEFAULT_EVE_SERVER_HEALTH_TIMEOUT_MS = 1_000; + +/** Returns whether an Eve server answers its health route successfully. */ +export async function isEveServerHealthy( + serverUrl: string, + options: { + readonly signal?: AbortSignal; + readonly timeoutMs?: number; + } = {}, +): Promise { + const timeoutSignal = AbortSignal.timeout( + options.timeoutMs ?? DEFAULT_EVE_SERVER_HEALTH_TIMEOUT_MS, + ); + const signal = + options.signal === undefined ? timeoutSignal : AbortSignal.any([options.signal, timeoutSignal]); + + try { + const healthUrl = new URL(EVE_HEALTH_ROUTE_PATH, serverUrl).toString(); + const response = await fetch(healthUrl, { redirect: "error", signal }); + return response.ok; + } catch { + return false; + } +} diff --git a/packages/eve/src/shared/network-address.test.ts b/packages/eve/src/shared/network-address.test.ts index 963ff8ab4..5ad147eba 100644 --- a/packages/eve/src/shared/network-address.test.ts +++ b/packages/eve/src/shared/network-address.test.ts @@ -1,6 +1,44 @@ import { describe, expect, it } from "vitest"; -import { isReservedIpAddress } from "#shared/network-address.js"; +import { + isLoopbackHostname, + isLoopbackServerUrl, + isReservedIpAddress, +} from "#shared/network-address.js"; + +describe("isLoopbackHostname", () => { + it("accepts the IPv4 loopback block, IPv6 loopback, and the localhost namespace", () => { + for (const host of ["localhost", "app.localhost", "127.0.0.1", "127.1.2.3", "::1", "[::1]"]) { + expect(isLoopbackHostname(host), host).toBe(true); + } + }); + + it("rejects wildcard binds, public hosts, and non-loopback IPs", () => { + for (const host of ["0.0.0.0", "::", "8.8.8.8", "example.com", "10.0.0.1"]) { + expect(isLoopbackHostname(host), host).toBe(false); + } + }); +}); + +describe("isLoopbackServerUrl", () => { + it("accepts http(s) URLs on loopback hosts", () => { + for (const url of ["http://127.0.0.1:2000/", "http://localhost:3000", "https://[::1]:8080/x"]) { + expect(isLoopbackServerUrl(url), url).toBe(true); + } + }); + + it("rejects non-loopback hosts, non-http schemes, and junk", () => { + for (const url of [ + "ws://localhost:2000/", + "http://evil.example/", + "http://0.0.0.0:2000/", + "ftp://127.0.0.1/", + "nope", + ]) { + expect(isLoopbackServerUrl(url), url).toBe(false); + } + }); +}); describe("isReservedIpAddress", () => { it("blocks link-local (cloud metadata), private, CGNAT, ULA, and unspecified addresses", () => { diff --git a/packages/eve/src/shared/network-address.ts b/packages/eve/src/shared/network-address.ts index 29f52caaa..59c79ac5c 100644 --- a/packages/eve/src/shared/network-address.ts +++ b/packages/eve/src/shared/network-address.ts @@ -1,5 +1,10 @@ import { BlockList, isIP } from "node:net"; +import { z } from "#compiled/zod/index.js"; + +/** HTTP(S) URL accepted as a development-server endpoint. */ +export const httpServerUrlSchema = z.url({ protocol: /^https?$/ }); + /** * Private, link-local, and otherwise reserved IP ranges that a framework-issued * outbound request to a caller-supplied URL must not target. This is the SSRF @@ -38,6 +43,33 @@ function normalizeAddress(host: string): string { return withoutZone; } +/** + * Returns whether `hostname` names the current machine's loopback interface. + * Accepts the full IPv4 loopback block, IPv6 loopback, and the RFC 6761 + * `localhost` namespace. Wildcard bind addresses such as `0.0.0.0` are not + * loopback connect targets. + */ +export function isLoopbackHostname(hostname: string): boolean { + const normalized = normalizeAddress(hostname).toLowerCase(); + + if (normalized === "localhost" || normalized.endsWith(".localhost")) { + return true; + } + + const family = isIP(normalized); + if (family === 4) { + return normalized.startsWith("127."); + } + + return family === 6 && normalized === "::1"; +} + +/** Returns whether `urlText` is an HTTP(S) URL with a loopback hostname. */ +export function isLoopbackServerUrl(urlText: string): boolean { + const parsed = httpServerUrlSchema.safeParse(urlText); + return parsed.success && isLoopbackHostname(new URL(parsed.data).hostname); +} + /** * Whether `host` is an IP literal in a private, link-local, or otherwise * reserved range that an outbound framework request must not target — an SSRF diff --git a/packages/eve/src/shared/result.test.ts b/packages/eve/src/shared/result.test.ts new file mode 100644 index 000000000..1e3be34e5 --- /dev/null +++ b/packages/eve/src/shared/result.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; + +import { err, ok, type Result } from "#shared/result.js"; + +describe("Result", () => { + it("constructs a success", () => { + expect(ok(42)).toEqual({ ok: true, value: 42 }); + }); + + it("constructs a failure", () => { + expect(err("nope")).toEqual({ ok: false, error: "nope" }); + }); + + it("narrows by the ok discriminant", () => { + function parseNumber(value: number): Result { + return Number.isNaN(value) ? err("nan") : ok(value); + } + + const result = parseNumber(1); + expect(result.ok ? result.value : result.error).toBe(1); + }); +}); diff --git a/packages/eve/src/shared/result.ts b/packages/eve/src/shared/result.ts new file mode 100644 index 000000000..36e8a14bb --- /dev/null +++ b/packages/eve/src/shared/result.ts @@ -0,0 +1,14 @@ +/** A typed success-or-failure value for expected, recoverable outcomes. */ +export type Result = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: E }; + +/** Wraps a value as a successful {@link Result}. */ +export function ok(value: T): Result { + return { ok: true, value }; +} + +/** Wraps an error as a failed {@link Result}. */ +export function err(error: E): Result { + return { ok: false, error }; +} diff --git a/packages/eve/test/scenarios/cli.scenario.test.ts b/packages/eve/test/scenarios/cli.scenario.test.ts index 541070a1e..4da5912aa 100644 --- a/packages/eve/test/scenarios/cli.scenario.test.ts +++ b/packages/eve/test/scenarios/cli.scenario.test.ts @@ -282,9 +282,12 @@ describe("runCli", () => { error: vi.fn(), log: vi.fn(), }; - const startHost = vi.fn(async () => { - throw new Error("dev started"); - }); + const startHost = vi.fn(() => ({ + start: async () => { + throw new Error("dev started"); + }, + close: async () => {}, + })); await expect( runCli([], logger, { From 7aa5279093a10e2791663d47a74fe491f383bc4d Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Thu, 25 Jun 2026 15:27:54 -0400 Subject: [PATCH 2/2] feat(eve): converge framework dev servers Signed-off-by: Rui Conti --- .changeset/bright-owls-converge.md | 5 + docs/guides/frontend/nextjs.mdx | 2 +- docs/guides/frontend/nuxt.mdx | 2 +- docs/guides/frontend/sveltekit.mdx | 2 +- ...red-development-server.integration.test.ts | 152 +++++++++ .../host/resolve-shared-development-server.ts | 314 ++++++++++++++++++ .../src/public/next/index.integration.test.ts | 23 +- .../public/next/server.integration.test.ts | 34 +- packages/eve/src/public/next/server.ts | 294 +--------------- .../nuxt/dev-server.integration.test.ts | 48 +-- .../eve/src/public/nuxt/dev-server.test.ts | 53 --- packages/eve/src/public/nuxt/dev-server.ts | 284 +--------------- packages/eve/src/public/nuxt/module.ts | 21 +- .../sveltekit/dev-server.integration.test.ts | 48 +-- .../src/public/sveltekit/dev-server.test.ts | 53 --- .../eve/src/public/sveltekit/dev-server.ts | 284 +--------------- .../eve/src/public/sveltekit/index.test.ts | 21 ++ packages/eve/src/public/sveltekit/index.ts | 20 +- 18 files changed, 634 insertions(+), 1026 deletions(-) create mode 100644 .changeset/bright-owls-converge.md create mode 100644 packages/eve/src/internal/nitro/host/resolve-shared-development-server.integration.test.ts create mode 100644 packages/eve/src/internal/nitro/host/resolve-shared-development-server.ts delete mode 100644 packages/eve/src/public/nuxt/dev-server.test.ts delete mode 100644 packages/eve/src/public/sveltekit/dev-server.test.ts diff --git a/.changeset/bright-owls-converge.md b/.changeset/bright-owls-converge.md new file mode 100644 index 000000000..3e7eda698 --- /dev/null +++ b/.changeset/bright-owls-converge.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Next.js, Nuxt, and SvelteKit development integrations now resolve the shared Eve dev server for an app root. They attach only to healthy loopback owners, so each app root converges on one server. diff --git a/docs/guides/frontend/nextjs.mdx b/docs/guides/frontend/nextjs.mdx index 62521f127..2a15122e9 100644 --- a/docs/guides/frontend/nextjs.mdx +++ b/docs/guides/frontend/nextjs.mdx @@ -74,7 +74,7 @@ For a public demo, use `none()` (also from `eve/channels/auth`) to skip authenti ## Dev vs deploy topology -- **Local dev.** `npm run dev` boots the eve dev server next to `next dev` and rewrites the eve routes over to it. The browser only ever talks to the Next.js origin. +- **Local dev.** `npm run dev` boots the eve dev server next to `next dev` and rewrites the eve routes over to it. If the same app root already has a server from `eve dev` or another framework process, Next.js reuses it. The browser only ever talks to the Next.js origin. - **Vercel.** The web app and the eve runtime deploy as a single project. The web app stays public; the eve runtime sits behind it on the same site origin. When the agent needs its own build step, set `eveBuildCommand`: ```ts diff --git a/docs/guides/frontend/nuxt.mdx b/docs/guides/frontend/nuxt.mdx index 832ad0947..ae8c5175a 100644 --- a/docs/guides/frontend/nuxt.mdx +++ b/docs/guides/frontend/nuxt.mdx @@ -73,7 +73,7 @@ For a public demo, use `none()` (also from `eve/channels/auth`) to skip authenti ## Dev vs deploy topology -- **Local dev.** `npm run dev` starts the eve dev server next to `nuxt dev` and proxies the eve routes through it. As far as the browser knows, everything is the Nuxt origin. +- **Local dev.** `npm run dev` starts the eve dev server next to `nuxt dev` and proxies the eve routes through it. If the same app root already has a server from `eve dev` or another framework process, Nuxt reuses it. As far as the browser knows, everything is the Nuxt origin. - **Vercel.** A single Vercel project carries both the Nuxt app and the eve runtime. The web app stays public; the runtime sits behind it on the same origin. Set `eveBuildCommand` when the agent needs its own build step: ```ts diff --git a/docs/guides/frontend/sveltekit.mdx b/docs/guides/frontend/sveltekit.mdx index 0ae554cf0..9ea300f15 100644 --- a/docs/guides/frontend/sveltekit.mdx +++ b/docs/guides/frontend/sveltekit.mdx @@ -82,7 +82,7 @@ For a public demo, use `none()` (also from `eve/channels/auth`) to skip authenti ## Dev vs deploy topology -- **Local dev.** `npm run dev` boots the eve dev server next to SvelteKit and proxies the eve routes to it, so the browser only ever hits the SvelteKit origin. `npm run build && npm run preview` behaves the same way: the preview server gets its own eve route proxy and either reuses the shared eve server or starts one. +- **Local dev.** `npm run dev` boots the eve dev server next to SvelteKit and proxies the eve routes to it, so the browser only ever hits the SvelteKit origin. If the same app root already has a server from `eve dev` or another framework process, SvelteKit reuses it. `npm run build && npm run preview` behaves the same way: the preview server gets its own eve route proxy and either reuses the shared eve server or starts one. - **Vercel.** The SvelteKit app and the eve runtime deploy as a single project. The web app is public; the eve runtime sits behind it on the same origin. Use `eveBuildCommand` for a project-specific agent build: ```ts diff --git a/packages/eve/src/internal/nitro/host/resolve-shared-development-server.integration.test.ts b/packages/eve/src/internal/nitro/host/resolve-shared-development-server.integration.test.ts new file mode 100644 index 000000000..e05024c8e --- /dev/null +++ b/packages/eve/src/internal/nitro/host/resolve-shared-development-server.integration.test.ts @@ -0,0 +1,152 @@ +import { EventEmitter } from "node:events"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +const spawnMock = vi.hoisted(() => vi.fn()); + +vi.mock("node:child_process", () => ({ + spawn: spawnMock, +})); + +import { DevelopmentServerState } from "#internal/nitro/host/dev-server-state.js"; + +import { + EVE_BASE_URL_ENV, + resolveSharedDevelopmentServer, +} from "./resolve-shared-development-server.js"; + +const temporaryRoots: string[] = []; + +interface MockChildProcess extends EventEmitter { + stderr: EventEmitter; + stdout: EventEmitter; + killed: boolean; + pid: number; + kill(signal?: NodeJS.Signals | number): boolean; +} + +function createMockChildProcess(pid: number): MockChildProcess { + const child = new EventEmitter() as MockChildProcess; + child.stderr = new EventEmitter(); + child.stdout = new EventEmitter(); + child.killed = false; + child.pid = pid; + child.kill = () => { + if (child.killed) return true; + child.killed = true; + child.emit("exit", null, "SIGTERM"); + return true; + }; + return child; +} + +async function createTempAppRoot(): Promise { + const appRoot = await mkdtemp(join(tmpdir(), "eve-shared-dev-server-")); + temporaryRoots.push(appRoot); + await writeFile(join(appRoot, "instructions.md"), "You are a test agent.\n"); + return appRoot; +} + +async function writeServerUrl(appRoot: string, origin: string): Promise { + await new DevelopmentServerState({ appRoot }).write(origin); +} + +afterEach(async () => { + spawnMock.mockReset(); + vi.unstubAllGlobals(); + delete process.env[EVE_BASE_URL_ENV]; + await Promise.all( + temporaryRoots.splice(0).map((root) => rm(root, { force: true, recursive: true })), + ); +}); + +describe("resolveSharedDevelopmentServer", () => { + it("returns a healthy URL already recorded for the app root", async () => { + const appRoot = await createTempAppRoot(); + await writeServerUrl(appRoot, "http://127.0.0.1:49152"); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(null, { status: 200 })), + ); + + await expect(resolveSharedDevelopmentServer({ appRoot, timeoutMs: 2_000 })).resolves.toEqual({ + origin: "http://127.0.0.1:49152", + }); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it("does not probe a non-loopback URL from the state record", async () => { + const appRoot = await createTempAppRoot(); + await writeServerUrl(appRoot, "http://192.168.1.20:49152"); + const fetchMock = vi.fn(async () => new Response(null, { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + await expect(resolveSharedDevelopmentServer({ appRoot, timeoutMs: 2_000 })).rejects.toThrow( + /published a non-loopback URL/u, + ); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it("starts a child after a stale record and returns its published URL", async () => { + const appRoot = await createTempAppRoot(); + await writeServerUrl(appRoot, "http://127.0.0.1:49152"); + const child = createMockChildProcess(2_147_483_646); + spawnMock.mockReturnValue(child); + vi.stubGlobal( + "fetch", + vi.fn( + async (url: string) => new Response(null, { status: url.includes("49153") ? 200 : 503 }), + ), + ); + + const resolution = resolveSharedDevelopmentServer({ appRoot, timeoutMs: 2_000 }); + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce()); + await writeServerUrl(appRoot, "http://127.0.0.1:49153"); + + const handle = await resolution; + expect(handle).toEqual({ + close: expect.any(Function), + origin: "http://127.0.0.1:49153", + process: child, + }); + await handle.close?.(); + }); + + it("reports a child that exits before a usable URL is recorded", async () => { + const appRoot = await createTempAppRoot(); + const child = createMockChildProcess(2_147_483_646); + spawnMock.mockReturnValue(child); + + const resolution = resolveSharedDevelopmentServer({ appRoot, timeoutMs: 2_000 }); + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce()); + child.emit("exit", 1, null); + + await expect(resolution).rejects.toThrow( + /failed before publishing a healthy URL \(exit code 1/u, + ); + }); + + it("keeps independent app roots on their own recorded URLs", async () => { + const firstAppRoot = await createTempAppRoot(); + const secondAppRoot = await createTempAppRoot(); + await writeServerUrl(firstAppRoot, "http://127.0.0.1:49152"); + await writeServerUrl(secondAppRoot, "http://127.0.0.1:49153"); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(null, { status: 200 })), + ); + + await expect( + resolveSharedDevelopmentServer({ appRoot: firstAppRoot, timeoutMs: 2_000 }), + ).resolves.toEqual({ origin: "http://127.0.0.1:49152" }); + await expect( + resolveSharedDevelopmentServer({ appRoot: secondAppRoot, timeoutMs: 2_000 }), + ).resolves.toEqual({ origin: "http://127.0.0.1:49153" }); + expect(spawnMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eve/src/internal/nitro/host/resolve-shared-development-server.ts b/packages/eve/src/internal/nitro/host/resolve-shared-development-server.ts new file mode 100644 index 000000000..ecc8daf5e --- /dev/null +++ b/packages/eve/src/internal/nitro/host/resolve-shared-development-server.ts @@ -0,0 +1,314 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +import { resolveDiscoveryProject } from "#discover/project.js"; +import { resolvePackageRoot } from "#internal/application/package.js"; +import { DevelopmentServerState } from "#internal/nitro/host/dev-server-state.js"; +import { isEveServerHealthy } from "#shared/eve-server-health.js"; +import { isLoopbackServerUrl } from "#shared/network-address.js"; + +export const EVE_BASE_URL_ENV = "EVE_BASE_URL"; + +const DEVELOPMENT_SERVER_POLL_MS = 100; +const DEVELOPMENT_SERVER_SHUTDOWN_GRACE_MS = 1_000; +const MAX_CHILD_OUTPUT_TAIL_LENGTH = 16_384; + +export interface SharedDevelopmentServerHandle { + readonly close?: () => Promise; + readonly origin: string; + readonly process?: ChildProcess; +} + +type ChildProcessOutcome = + | { readonly kind: "error"; readonly error: Error } + | { + readonly kind: "exit"; + readonly code: number | null; + readonly signal: NodeJS.Signals | null; + }; + +interface DevelopmentServerCandidate { + readonly process: ChildProcess; + settled: Promise; + outcome: ChildProcessOutcome | undefined; + stderrTail: string; + stdoutTail: string; +} + +/** + * Resolves a development server for a framework adapter. + * + * A healthy loopback URL in the app root's state file is reused. Otherwise the + * adapter starts the normal `eve dev` child and waits for that child to publish + * its ready URL. + */ +export async function resolveSharedDevelopmentServer(input: { + readonly appRoot: string; + readonly timeoutMs: number; +}): Promise { + const deadline = Date.now() + input.timeoutMs; + const project = await withDeadline(resolveDiscoveryProject(input.appRoot), deadline, () => + createResolutionTimeout({ + appRoot: input.appRoot, + candidate: undefined, + serverUrl: undefined, + timeoutMs: input.timeoutMs, + }), + ); + const state = new DevelopmentServerState(project); + let candidate: DevelopmentServerCandidate | undefined; + let serverUrl: string | undefined; + + try { + for (;;) { + throwIfDeadlineReached({ + appRoot: project.appRoot, + candidate, + deadline, + serverUrl, + timeoutMs: input.timeoutMs, + }); + serverUrl = await withDeadline(state.read(), deadline, () => + createResolutionTimeout({ + appRoot: project.appRoot, + candidate, + serverUrl, + timeoutMs: input.timeoutMs, + }), + ); + + if (serverUrl !== undefined) { + if (!isLoopbackServerUrl(serverUrl)) { + throw new Error( + `Development server for "${project.appRoot}" published a non-loopback URL (${serverUrl}); refusing to attach.`, + ); + } + + const healthy = await withDeadline( + isEveServerHealthy(serverUrl, { + timeoutMs: Math.min(remainingTime(deadline), 1_000), + }), + deadline, + () => + createResolutionTimeout({ + appRoot: project.appRoot, + candidate, + serverUrl, + timeoutMs: input.timeoutMs, + }), + ); + if (healthy) { + return candidate === undefined || candidate.outcome !== undefined + ? { origin: serverUrl } + : createOwnedDevelopmentServerHandle(serverUrl, candidate); + } + } + + if (candidate?.outcome !== undefined) { + throw createCandidateFailure(candidate, project.appRoot); + } + candidate ??= spawnDevelopmentServerCandidate(project.appRoot); + await waitForStateChange(candidate, deadline); + } + } catch (error) { + if (candidate !== undefined) { + try { + await terminateCandidate(candidate); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + `Failed to resolve and clean up the development-server candidate for "${project.appRoot}".`, + ); + } + } + throw error; + } +} + +function spawnDevelopmentServerCandidate(appRoot: string): DevelopmentServerCandidate { + const child = spawn( + process.execPath, + [join(resolvePackageRoot(), "bin", "eve.js"), "dev", "--no-ui", "--port", "0"], + { + cwd: appRoot, + env: { ...process.env }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + const candidate: DevelopmentServerCandidate = { + outcome: undefined, + process: child, + settled: Promise.resolve(), + stderrTail: "", + stdoutTail: "", + }; + candidate.settled = new Promise((resolvePromise) => { + const settle = (outcome: ChildProcessOutcome) => { + if (candidate.outcome !== undefined) { + return; + } + candidate.outcome = outcome; + resolvePromise(); + }; + + child.once("error", (error) => settle({ error, kind: "error" })); + child.once("exit", (code, signal) => settle({ code, kind: "exit", signal })); + }); + child.stdout?.on("data", (chunk: Buffer) => { + process.stdout.write(chunk); + candidate.stdoutTail = appendOutputTail(candidate.stdoutTail, chunk); + }); + child.stderr?.on("data", (chunk: Buffer) => { + process.stderr.write(chunk); + candidate.stderrTail = appendOutputTail(candidate.stderrTail, chunk); + }); + return candidate; +} + +function appendOutputTail(current: string, chunk: Buffer): string { + return `${current}${chunk.toString("utf8")}`.slice(-MAX_CHILD_OUTPUT_TAIL_LENGTH); +} + +async function waitForStateChange( + candidate: DevelopmentServerCandidate, + deadline: number, +): Promise { + const waitMs = Math.min(DEVELOPMENT_SERVER_POLL_MS, remainingTime(deadline)); + await Promise.race([delay(waitMs), candidate.settled]); +} + +function remainingTime(deadline: number): number { + return Math.max(0, deadline - Date.now()); +} + +function throwIfDeadlineReached(input: { + readonly appRoot: string; + readonly candidate: DevelopmentServerCandidate | undefined; + readonly deadline: number; + readonly serverUrl: string | undefined; + readonly timeoutMs: number; +}): void { + if (Date.now() < input.deadline) { + return; + } + + throw createResolutionTimeout(input); +} + +function createResolutionTimeout(input: { + readonly appRoot: string; + readonly candidate: DevelopmentServerCandidate | undefined; + readonly serverUrl: string | undefined; + readonly timeoutMs: number; +}): Error { + const stateStatus = + input.serverUrl === undefined + ? "no server URL was recorded" + : `recorded URL ${input.serverUrl} was not healthy`; + const candidateStatus = + input.candidate === undefined + ? "no child was spawned" + : input.candidate.outcome === undefined + ? `child ${String(input.candidate.process.pid)} is still running` + : "the spawned child exited before a reusable server became ready"; + return new Error( + `Timed out after ${input.timeoutMs}ms resolving the development server for "${input.appRoot}" (${stateStatus}; ${candidateStatus}).`, + ); +} + +async function withDeadline( + operation: Promise, + deadline: number, + createTimeoutError: () => Error, +): Promise { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + throw createTimeoutError(); + } + + const timeoutController = new AbortController(); + try { + return await Promise.race([ + operation, + delay(remainingMs, undefined, { signal: timeoutController.signal }).then(() => { + throw createTimeoutError(); + }), + ]); + } finally { + timeoutController.abort(); + } +} + +function createCandidateFailure(candidate: DevelopmentServerCandidate, appRoot: string): Error { + const outcome = candidate.outcome; + if (outcome === undefined) { + return new Error(`Development-server child for "${appRoot}" did not publish a healthy URL.`); + } + + const summary = + outcome.kind === "error" + ? outcome.error.message + : `exit code ${String(outcome.code)}, signal ${String(outcome.signal)}`; + const output = [ + candidate.stdoutTail.length > 0 ? `stdout:\n${candidate.stdoutTail}` : undefined, + candidate.stderrTail.length > 0 ? `stderr:\n${candidate.stderrTail}` : undefined, + ] + .filter((part): part is string => part !== undefined) + .join("\n\n"); + return new Error( + `Development-server child for "${appRoot}" failed before publishing a healthy URL (${summary}).${output.length > 0 ? `\n\n${output}` : ""}`, + outcome.kind === "error" ? { cause: outcome.error } : undefined, + ); +} + +async function terminateCandidate(candidate: DevelopmentServerCandidate): Promise { + if (candidate.outcome !== undefined) { + return; + } + + candidate.process.kill("SIGTERM"); + await waitForCandidateExit(candidate, DEVELOPMENT_SERVER_SHUTDOWN_GRACE_MS); + if (candidate.outcome !== undefined) { + return; + } + + candidate.process.kill("SIGKILL"); + await waitForCandidateExit(candidate, DEVELOPMENT_SERVER_SHUTDOWN_GRACE_MS); + if (candidate.outcome === undefined) { + throw new Error( + `Development-server child ${String(candidate.process.pid)} did not exit after SIGTERM and SIGKILL.`, + ); + } +} + +async function waitForCandidateExit( + candidate: DevelopmentServerCandidate, + timeoutMs: number, +): Promise { + await Promise.race([candidate.settled, delay(timeoutMs)]); +} + +function createOwnedDevelopmentServerHandle( + origin: string, + candidate: DevelopmentServerCandidate, +): SharedDevelopmentServerHandle { + const child = candidate.process; + const close = () => { + if (!child.killed) { + child.kill(); + } + }; + const removeHooks = () => { + process.off("exit", close); + }; + + process.once("exit", close); + child.once("error", removeHooks); + child.once("exit", removeHooks); + return { + close: () => terminateCandidate(candidate), + origin, + process: child, + }; +} diff --git a/packages/eve/src/public/next/index.integration.test.ts b/packages/eve/src/public/next/index.integration.test.ts index a505b1aa0..7ef7b69f2 100644 --- a/packages/eve/src/public/next/index.integration.test.ts +++ b/packages/eve/src/public/next/index.integration.test.ts @@ -4,6 +4,8 @@ import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { DevelopmentServerState } from "#internal/nitro/host/dev-server-state.js"; + import { EVE_NEXT_SERVICE_PREFIX, withEve, @@ -243,7 +245,7 @@ describe("withEve Vercel config", () => { }); }); - it("reuses an app-local development server registry before spawning", async () => { + it("reuses the app root's canonical development server", async () => { const appRoot = await createTempAppRoot(); process.chdir(appRoot); const resolvedAppRoot = process.cwd(); @@ -252,27 +254,14 @@ describe("withEve Vercel config", () => { "fetch", vi.fn(async () => new Response(null, { status: 200 })), ); - await mkdir(join(resolvedAppRoot, ".eve"), { - recursive: true, - }); - await writeFile( - join(resolvedAppRoot, ".eve", "next-dev-server.json"), - `${JSON.stringify( - { - appRoot: resolvedAppRoot, - origin: "http://127.0.0.1:49152", - pid: null, - updatedAt: new Date().toISOString(), - }, - null, - 2, - )}\n`, - ); + await writeFile(join(resolvedAppRoot, "instructions.md"), "You are a test agent.\n"); + await new DevelopmentServerState({ appRoot: resolvedAppRoot }).write("http://127.0.0.1:49152"); const config = await resolveConfig(withEve({})); const rewrites = await config.rewrites?.(); expect(fetch).toHaveBeenCalledWith("http://127.0.0.1:49152/eve/v1/health", { + redirect: "error", signal: expect.any(AbortSignal), }); expect(getBeforeFiles(rewrites)).toContainEqual({ diff --git a/packages/eve/src/public/next/server.integration.test.ts b/packages/eve/src/public/next/server.integration.test.ts index e8ec83db1..adb8e61c0 100644 --- a/packages/eve/src/public/next/server.integration.test.ts +++ b/packages/eve/src/public/next/server.integration.test.ts @@ -1,10 +1,12 @@ import { EventEmitter } from "node:events"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { DevelopmentServerState } from "#internal/nitro/host/dev-server-state.js"; + const spawnMock = vi.hoisted(() => vi.fn()); vi.mock("node:child_process", () => ({ @@ -28,7 +30,7 @@ function createMockChildProcess(): MockChildProcess { child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); child.killed = false; - child.pid = 12345; + child.pid = process.pid; child.kill = () => { child.killed = true; child.emit("exit", null, "SIGTERM"); @@ -40,6 +42,7 @@ describe("resolveEveDestinationPrefix", () => { afterEach(async () => { spawnMock.mockReset(); vi.unstubAllEnvs(); + vi.unstubAllGlobals(); await Promise.all( tempRoots.splice(0).map((root) => rm(root, { @@ -50,8 +53,12 @@ describe("resolveEveDestinationPrefix", () => { ); }); - it("ignores non-server URLs in dev server output while waiting for the listening URL", async () => { + it("resolves the canonical state published by its child", async () => { vi.stubEnv("NODE_ENV", "development"); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(null, { status: 200 })), + ); const appRoot = await createTempAppRoot(); const child = createMockChildProcess(); spawnMock.mockReturnValue(child); @@ -65,30 +72,17 @@ describe("resolveEveDestinationPrefix", () => { await vi.waitFor(() => { expect(spawnMock).toHaveBeenCalledTimes(1); }); - child.stdout.emit( - "data", - Buffer.from('dependency metadata: "homepage": "https://rolldown.rs/"\n'), - ); - child.stdout.emit("data", Buffer.from("docs: open http://localhost for details\n")); - child.stderr.emit("data", Buffer.from("dev server listening at http://127.0.0.1:33449\n")); + child.stdout.emit("data", Buffer.from("eve child started\n")); + await new DevelopmentServerState({ appRoot }).write("http://127.0.0.1:33449"); await expect(destination).resolves.toBe("http://127.0.0.1:33449"); - await expect(readRegisteredOrigin(appRoot)).resolves.toBe("http://127.0.0.1:33449"); + child.kill(); }); }); async function createTempAppRoot(): Promise { const root = await mkdtemp(join(tmpdir(), "eve-next-server-")); tempRoots.push(root); + await writeFile(join(root, "instructions.md"), "You are a test agent.\n"); return root; } - -async function readRegisteredOrigin(appRoot: string): Promise { - const registry = JSON.parse( - await readFile(join(appRoot, ".eve", "next-dev-server.json"), "utf8"), - ) as { readonly origin?: unknown }; - if (typeof registry.origin !== "string") { - throw new Error("eve dev server registry did not record a string origin."); - } - return registry.origin; -} diff --git a/packages/eve/src/public/next/server.ts b/packages/eve/src/public/next/server.ts index 91cd25778..20080fcc7 100644 --- a/packages/eve/src/public/next/server.ts +++ b/packages/eve/src/public/next/server.ts @@ -1,36 +1,25 @@ import { spawn, type ChildProcess } from "node:child_process"; import { existsSync } from "node:fs"; -import { mkdir, open, readFile, rm, stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { resolvePackageRoot } from "#internal/application/package.js"; -import { EVE_ROUTE_PREFIX } from "#protocol/routes.js"; +import { + EVE_BASE_URL_ENV, + resolveSharedDevelopmentServer, +} from "#internal/nitro/host/resolve-shared-development-server.js"; +import { isLoopbackHostname } from "#shared/network-address.js"; -const EVE_BASE_URL_ENV = "EVE_BASE_URL"; const DEFAULT_SERVER_READY_TIMEOUT_MS = 180_000; -const DEV_SERVER_REGISTRY_TIMEOUT_MS = 180_000; -const DEV_SERVER_REGISTRY_POLL_MS = 100; -const DEV_SERVER_STALE_LOCK_MS = 30_000; -const EVE_CACHE_DIRECTORY_NAME = ".eve"; -const EVE_NEXT_DEV_SERVER_FILE_NAME = "next-dev-server.json"; -const EVE_NEXT_DEV_SERVER_LOCK_FILE_NAME = "next-dev-server.lock"; +const DEVELOPMENT_SERVER_TIMEOUT_MS = 180_000; const SERVER_URL_CANDIDATE_PATTERN = /https?:\/\/[^\s"'<>]+/g; const NEXT_PHASE_PRODUCTION_BUILD = "phase-production-build"; -interface EveProcessHandle { - readonly origin: string; - readonly process?: ChildProcess; -} - interface EveNextGlobalState { readonly servers: Map>; } -interface EveDevServerRegistry { - readonly appRoot: string; +interface EveProcessHandle { readonly origin: string; - readonly pid: number | null; - readonly updatedAt: string; + readonly process?: ChildProcess; } const globalStateSymbol = Symbol.for("eve.next.state"); @@ -47,10 +36,6 @@ function getGlobalState(): EveNextGlobalState { return globalWithState[globalStateSymbol]; } -function joinRoutePrefix(prefix: string, path: string): string { - return `${prefix.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`; -} - function normalizeOrigin(origin: string): string { return new URL(origin).origin; } @@ -65,201 +50,8 @@ function readEveBaseUrlEnvironment(): string | undefined { return normalizeOrigin(configuredUrl); } -function isNodeErrorWithCode(error: unknown, code: string): boolean { - return error instanceof Error && "code" in error && error.code === code; -} - -function delay(ms: number): Promise { - return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -function resolveEveCacheDirectory(appRoot: string): string { - return join(appRoot, EVE_CACHE_DIRECTORY_NAME); -} - -function resolveEveDevServerRegistryPath(appRoot: string): string { - return join(resolveEveCacheDirectory(appRoot), EVE_NEXT_DEV_SERVER_FILE_NAME); -} - -function resolveEveDevServerLockPath(appRoot: string): string { - return join(resolveEveCacheDirectory(appRoot), EVE_NEXT_DEV_SERVER_LOCK_FILE_NAME); -} - -function normalizeDevServerRegistry(value: unknown): EveDevServerRegistry | undefined { - if (!isRecord(value)) { - return undefined; - } - - if ( - typeof value.appRoot !== "string" || - typeof value.origin !== "string" || - typeof value.updatedAt !== "string" - ) { - return undefined; - } - - if (value.pid !== null && typeof value.pid !== "number") { - return undefined; - } - - try { - return { - appRoot: value.appRoot, - origin: normalizeOrigin(value.origin), - pid: value.pid, - updatedAt: value.updatedAt, - }; - } catch { - return undefined; - } -} - -async function isEveServerHealthy(origin: string): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => { - controller.abort(); - }, 1_000); - - try { - const response = await fetch(joinRoutePrefix(origin, `${EVE_ROUTE_PREFIX}/health`), { - signal: controller.signal, - }); - - return response.ok; - } catch { - return false; - } finally { - clearTimeout(timeout); - } -} - -async function readUsableEveDevServerRegistry(appRoot: string): Promise { - try { - const registry = normalizeDevServerRegistry( - JSON.parse(await readFile(resolveEveDevServerRegistryPath(appRoot), "utf8")) as unknown, - ); - - if (registry === undefined || registry.appRoot !== appRoot) { - return undefined; - } - - if (!(await isEveServerHealthy(registry.origin))) { - return undefined; - } - - process.env[EVE_BASE_URL_ENV] = registry.origin; - return registry.origin; - } catch (error) { - if (isNodeErrorWithCode(error, "ENOENT")) { - return undefined; - } - - throw error; - } -} - -async function writeEveDevServerRegistry(appRoot: string, handle: EveProcessHandle): Promise { - await mkdir(resolveEveCacheDirectory(appRoot), { - recursive: true, - }); - await writeFile( - resolveEveDevServerRegistryPath(appRoot), - `${JSON.stringify( - { - appRoot, - origin: handle.origin, - pid: handle.process?.pid ?? null, - updatedAt: new Date().toISOString(), - } satisfies EveDevServerRegistry, - null, - 2, - )}\n`, - ); -} - -async function removeStaleEveDevServerLock(lockPath: string): Promise { - try { - const lockStat = await stat(lockPath); - if (Date.now() - lockStat.mtimeMs > DEV_SERVER_STALE_LOCK_MS) { - await rm(lockPath, { - force: true, - }); - } - } catch (error) { - if (!isNodeErrorWithCode(error, "ENOENT")) { - throw error; - } - } -} - -async function acquireEveDevServerLock( - appRoot: string, - timeoutMs: number, -): Promise<() => Promise> { - const cacheDirectory = resolveEveCacheDirectory(appRoot); - const lockPath = resolveEveDevServerLockPath(appRoot); - const deadline = Date.now() + timeoutMs; - - await mkdir(cacheDirectory, { - recursive: true, - }); - - while (true) { - try { - const lockFile = await open(lockPath, "wx"); - await lockFile.writeFile(`${String(process.pid)}\n`); - await lockFile.close(); - - return async () => { - await rm(lockPath, { - force: true, - }); - }; - } catch (error) { - if (!isNodeErrorWithCode(error, "EEXIST")) { - throw error; - } - - const registeredOrigin = await readUsableEveDevServerRegistry(appRoot); - if (registeredOrigin !== undefined) { - return async () => {}; - } - - await removeStaleEveDevServerLock(lockPath); - - if (Date.now() > deadline) { - throw new Error( - `Timed out after ${timeoutMs}ms waiting for another Next.js process to start eve.`, - ); - } - - await delay(DEV_SERVER_REGISTRY_POLL_MS); - } - } -} - -function createEveBinaryPath(): string { - return join(resolvePackageRoot(), "bin", "eve.js"); -} - -function isLoopbackHostname(hostname: string): boolean { - return ( - hostname === "localhost" || - hostname === "::1" || - hostname === "[::1]" || - /^127(?:\.\d{1,3}){3}$/.test(hostname) - ); -} - function parseLocalServerOrigin(urlText: string): string | undefined { const url = URL.parse(urlText); - // Dev-server discovery reads mixed subprocess output. Build metadata and - // dependency warnings can print unrelated URLs before eve reports its listener, - // but withEve only owns the app-local loopback server it started. if ( url === null || (url.protocol !== "http:" && url.protocol !== "https:") || @@ -274,8 +66,7 @@ function parseLocalServerOrigin(urlText: string): string | undefined { function findLocalServerOrigin(output: string): string | undefined { for (const match of output.matchAll(SERVER_URL_CANDIDATE_PATTERN)) { - const candidate = match[0]; - const origin = parseLocalServerOrigin(candidate); + const origin = parseLocalServerOrigin(match[0]); if (origin !== undefined) { return origin; } @@ -300,14 +91,11 @@ function startServerProcess(input: { }, stdio: ["ignore", "pipe", "pipe"], }); + const timeoutMs = input.timeoutMs ?? DEFAULT_SERVER_READY_TIMEOUT_MS; const timeout = setTimeout(() => { child.kill(); - reject( - new Error( - `Timed out after ${input.timeoutMs ?? DEFAULT_SERVER_READY_TIMEOUT_MS}ms waiting for eve to print its server URL.`, - ), - ); - }, input.timeoutMs ?? DEFAULT_SERVER_READY_TIMEOUT_MS); + reject(new Error(`Timed out after ${timeoutMs}ms waiting for the server URL.`)); + }, timeoutMs); const cleanup = () => { clearTimeout(timeout); @@ -322,22 +110,18 @@ function startServerProcess(input: { cleanup(); reject( new Error( - `eve server process exited before printing its server URL (code ${String(code)}, signal ${String(signal)}).`, + `Server process exited before printing its URL (code ${String(code)}, signal ${String(signal)}).`, ), ); }; const handleOutput = (chunk: Buffer) => { const origin = findLocalServerOrigin(chunk.toString("utf8")); - if (origin === undefined) { return; } cleanup(); - resolvePromise({ - origin, - process: child, - }); + resolvePromise({ origin, process: child }); }; const handleStdout = (chunk: Buffer) => { process.stdout.write(chunk); @@ -370,22 +154,9 @@ function installProcessShutdown(handle: EveProcessHandle): EveProcessHandle { process.once("beforeExit", close); process.once("exit", close); - return handle; } -function startEveDevServer(appRoot: string, timeoutMs: number): Promise { - return startServerProcess({ - args: [createEveBinaryPath(), "dev", "--no-ui", "--port", "0"], - command: process.execPath, - cwd: appRoot, - timeoutMs, - }).then((handle) => { - process.env[EVE_BASE_URL_ENV] = handle.origin; - return installProcessShutdown(handle); - }); -} - function startEveProductionServer(input: { readonly appRoot: string; readonly origin: string; @@ -411,35 +182,6 @@ function startEveProductionServer(input: { }).then(installProcessShutdown); } -async function resolveSharedEveDevServer( - appRoot: string, - timeoutMs: number, -): Promise { - const registeredOrigin = await readUsableEveDevServerRegistry(appRoot); - if (registeredOrigin !== undefined) { - return { - origin: registeredOrigin, - }; - } - - const releaseLock = await acquireEveDevServerLock(appRoot, timeoutMs); - - try { - const lockedRegisteredOrigin = await readUsableEveDevServerRegistry(appRoot); - if (lockedRegisteredOrigin !== undefined) { - return { - origin: lockedRegisteredOrigin, - }; - } - - const handle = await startEveDevServer(appRoot, timeoutMs); - await writeEveDevServerRegistry(appRoot, handle); - return handle; - } finally { - await releaseLock(); - } -} - export async function resolveEveDestinationPrefix(input: { readonly appRoot: string; readonly devServerTimeoutMs?: number; @@ -493,10 +235,10 @@ export async function resolveEveDestinationPrefix(input: { let server = state.servers.get(key); if (server === undefined) { - server = resolveSharedEveDevServer( - input.appRoot, - input.devServerTimeoutMs ?? DEV_SERVER_REGISTRY_TIMEOUT_MS, - ).catch((error) => { + server = resolveSharedDevelopmentServer({ + appRoot: input.appRoot, + timeoutMs: input.devServerTimeoutMs ?? DEVELOPMENT_SERVER_TIMEOUT_MS, + }).catch((error) => { state.servers.delete(key); throw error; }); diff --git a/packages/eve/src/public/nuxt/dev-server.integration.test.ts b/packages/eve/src/public/nuxt/dev-server.integration.test.ts index 1c371c91e..394d7ddc8 100644 --- a/packages/eve/src/public/nuxt/dev-server.integration.test.ts +++ b/packages/eve/src/public/nuxt/dev-server.integration.test.ts @@ -1,24 +1,24 @@ -import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { DevelopmentServerState } from "#internal/nitro/host/dev-server-state.js"; + import { EVE_BASE_URL_ENV, resolveSharedEveDevServer } from "./dev-server.js"; async function createTempAppRoot(): Promise { - return await mkdtemp(join(tmpdir(), "eve-nuxt-dev-server-")); + const appRoot = await mkdtemp(join(tmpdir(), "eve-nuxt-dev-server-")); + await writeFile(join(appRoot, "instructions.md"), "You are a test agent.\n"); + return appRoot; } -async function writeRegistry(appRoot: string, registry: Record): Promise { - await mkdir(join(appRoot, ".eve"), { recursive: true }); - await writeFile( - join(appRoot, ".eve", "nuxt-dev-server.json"), - `${JSON.stringify(registry, null, 2)}\n`, - ); +async function publishReadyServer(appRoot: string, origin: string): Promise { + await new DevelopmentServerState({ appRoot }).write(origin); } -afterEach(() => { +afterEach(async () => { vi.unstubAllEnvs(); vi.unstubAllGlobals(); delete process.env[EVE_BASE_URL_ENV]; @@ -30,20 +30,20 @@ describe("resolveSharedEveDevServer", () => { const fetchMock = vi.fn(async () => new Response(null, { status: 200 })); vi.stubGlobal("fetch", fetchMock); - await writeRegistry(appRoot, { - appRoot, - origin: "http://127.0.0.1:49152", - pid: null, - updatedAt: new Date().toISOString(), - }); - - const handle = await resolveSharedEveDevServer(appRoot); - - expect(handle).toEqual({ origin: "http://127.0.0.1:49152" }); - expect(handle.process).toBeUndefined(); - expect(process.env[EVE_BASE_URL_ENV]).toBe("http://127.0.0.1:49152"); - expect(fetchMock).toHaveBeenCalledWith("http://127.0.0.1:49152/eve/v1/health", { - signal: expect.any(AbortSignal), - }); + await publishReadyServer(appRoot, "http://127.0.0.1:49152"); + + try { + const handle = await resolveSharedEveDevServer(appRoot); + + expect(handle).toEqual({ origin: "http://127.0.0.1:49152" }); + expect(handle.process).toBeUndefined(); + expect(process.env[EVE_BASE_URL_ENV]).toBeUndefined(); + expect(fetchMock).toHaveBeenCalledWith("http://127.0.0.1:49152/eve/v1/health", { + redirect: "error", + signal: expect.any(AbortSignal), + }); + } finally { + await rm(appRoot, { force: true, recursive: true }); + } }); }); diff --git a/packages/eve/src/public/nuxt/dev-server.test.ts b/packages/eve/src/public/nuxt/dev-server.test.ts deleted file mode 100644 index 4f2ad69de..000000000 --- a/packages/eve/src/public/nuxt/dev-server.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { normalizeDevServerRegistry } from "./dev-server.js"; - -describe("normalizeDevServerRegistry", () => { - it("normalizes a well-formed record and canonicalizes the origin", () => { - expect( - normalizeDevServerRegistry({ - appRoot: "/app", - origin: "http://127.0.0.1:49152/", - pid: 1234, - updatedAt: "2026-05-28T00:00:00.000Z", - }), - ).toEqual({ - appRoot: "/app", - origin: "http://127.0.0.1:49152", - pid: 1234, - updatedAt: "2026-05-28T00:00:00.000Z", - }); - }); - - it("accepts a null pid", () => { - expect( - normalizeDevServerRegistry({ - appRoot: "/app", - origin: "http://127.0.0.1:49152", - pid: null, - updatedAt: "2026-05-28T00:00:00.000Z", - })?.pid, - ).toBeNull(); - }); - - const invalidCases: readonly { readonly label: string; readonly value: unknown }[] = [ - { label: "a scalar", value: "not a record" }, - { label: "null", value: null }, - { label: "an array", value: ["array"] }, - { label: "missing appRoot", value: { origin: "http://x", pid: null, updatedAt: "now" } }, - { label: "missing origin", value: { appRoot: "/app", pid: null, updatedAt: "now" } }, - { label: "missing updatedAt", value: { appRoot: "/app", origin: "http://x", pid: null } }, - { - label: "a non-number pid", - value: { appRoot: "/app", origin: "http://x", pid: "1", updatedAt: "now" }, - }, - { - label: "an invalid origin", - value: { appRoot: "/app", origin: "not a url", pid: null, updatedAt: "now" }, - }, - ]; - - it.each(invalidCases)("returns undefined for $label", ({ value }) => { - expect(normalizeDevServerRegistry(value)).toBeUndefined(); - }); -}); diff --git a/packages/eve/src/public/nuxt/dev-server.ts b/packages/eve/src/public/nuxt/dev-server.ts index e0d78cb4a..59f760010 100644 --- a/packages/eve/src/public/nuxt/dev-server.ts +++ b/packages/eve/src/public/nuxt/dev-server.ts @@ -1,283 +1,27 @@ -import { spawn, type ChildProcess } from "node:child_process"; -import { mkdir, open, readFile, rm, stat, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import type { ChildProcess } from "node:child_process"; -import { resolvePackageRoot } from "#internal/application/package.js"; -import { EVE_ROUTE_PREFIX } from "#protocol/routes.js"; +import { + EVE_BASE_URL_ENV, + resolveSharedDevelopmentServer, +} from "#internal/nitro/host/resolve-shared-development-server.js"; -import { joinRoutePrefix, normalizeOrigin } from "./routing.js"; - -export const EVE_BASE_URL_ENV = "EVE_BASE_URL"; - -const DEFAULT_SERVER_READY_TIMEOUT_MS = 30_000; -const DEV_SERVER_REGISTRY_TIMEOUT_MS = 30_000; -const DEV_SERVER_REGISTRY_POLL_MS = 100; -const DEV_SERVER_STALE_LOCK_MS = 30_000; -const EVE_CACHE_DIRECTORY_NAME = ".eve"; -const EVE_NUXT_DEV_SERVER_FILE_NAME = "nuxt-dev-server.json"; -const EVE_NUXT_DEV_SERVER_LOCK_FILE_NAME = "nuxt-dev-server.lock"; -const LOCAL_SERVER_URL_PATTERN = /https?:\/\/(?:\[[^\]\s]+\]|[^\s/:[\]]+)(?::\d+)?/; +export { EVE_BASE_URL_ENV }; export interface EveProcessHandle { readonly origin: string; readonly process?: ChildProcess; } -export interface EveDevServerRegistry { - readonly appRoot: string; - readonly origin: string; - readonly pid: number | null; - readonly updatedAt: string; -} - -function isNodeErrorWithCode(error: unknown, code: string): boolean { - return error instanceof Error && "code" in error && error.code === code; -} - -function delay(ms: number): Promise { - return new Promise((r) => setTimeout(r, ms)); -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -function resolveEveCacheDirectory(appRoot: string): string { - return join(appRoot, EVE_CACHE_DIRECTORY_NAME); -} - -function resolveEveDevServerRegistryPath(appRoot: string): string { - return join(resolveEveCacheDirectory(appRoot), EVE_NUXT_DEV_SERVER_FILE_NAME); -} - -function resolveEveDevServerLockPath(appRoot: string): string { - return join(resolveEveCacheDirectory(appRoot), EVE_NUXT_DEV_SERVER_LOCK_FILE_NAME); +interface EveDevelopmentServerHandle extends EveProcessHandle { + readonly close?: () => Promise; } -/** - * Parse and validate a persisted dev-server registry record. Returns - * `undefined` for anything that is not a well-formed registry so callers fall - * back to spawning a fresh server. - */ -export function normalizeDevServerRegistry(value: unknown): EveDevServerRegistry | undefined { - if (!isRecord(value)) return undefined; - if ( - typeof value.appRoot !== "string" || - typeof value.origin !== "string" || - typeof value.updatedAt !== "string" - ) - return undefined; - if (value.pid !== null && typeof value.pid !== "number") return undefined; - try { - return { - appRoot: value.appRoot, - origin: normalizeOrigin(value.origin), - pid: value.pid, - updatedAt: value.updatedAt, - }; - } catch { - return undefined; - } -} - -async function isEveServerHealthy(origin: string): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 1_000); - try { - const response = await fetch(joinRoutePrefix(origin, `${EVE_ROUTE_PREFIX}/health`), { - signal: controller.signal, - }); - return response.ok; - } catch { - return false; - } finally { - clearTimeout(timeout); - } -} - -async function readUsableEveDevServerRegistry(appRoot: string): Promise { - try { - const registry = normalizeDevServerRegistry( - JSON.parse(await readFile(resolveEveDevServerRegistryPath(appRoot), "utf8")) as unknown, - ); - if (registry === undefined || registry.appRoot !== appRoot) return undefined; - if (!(await isEveServerHealthy(registry.origin))) return undefined; - return registry.origin; - } catch (error) { - if (isNodeErrorWithCode(error, "ENOENT")) return undefined; - throw error; - } -} +const DEVELOPMENT_SERVER_TIMEOUT_MS = 30_000; -async function writeEveDevServerRegistry(appRoot: string, handle: EveProcessHandle): Promise { - await mkdir(resolveEveCacheDirectory(appRoot), { recursive: true }); - await writeFile( - resolveEveDevServerRegistryPath(appRoot), - `${JSON.stringify( - { - appRoot, - origin: handle.origin, - pid: handle.process?.pid ?? null, - updatedAt: new Date().toISOString(), - } satisfies EveDevServerRegistry, - null, - 2, - )}\n`, - ); -} - -async function removeStaleEveDevServerLock(lockPath: string): Promise { - try { - const lockStat = await stat(lockPath); - if (Date.now() - lockStat.mtimeMs > DEV_SERVER_STALE_LOCK_MS) { - await rm(lockPath, { force: true }); - } - } catch (error) { - if (!isNodeErrorWithCode(error, "ENOENT")) throw error; - } -} - -async function acquireEveDevServerLock(appRoot: string): Promise<() => Promise> { - const cacheDirectory = resolveEveCacheDirectory(appRoot); - const lockPath = resolveEveDevServerLockPath(appRoot); - const deadline = Date.now() + DEV_SERVER_REGISTRY_TIMEOUT_MS; - await mkdir(cacheDirectory, { recursive: true }); - - while (true) { - try { - const lockFile = await open(lockPath, "wx"); - await lockFile.writeFile(`${String(process.pid)}\n`); - await lockFile.close(); - return async () => { - await rm(lockPath, { force: true }); - }; - } catch (error) { - if (!isNodeErrorWithCode(error, "EEXIST")) throw error; - const registeredOrigin = await readUsableEveDevServerRegistry(appRoot); - if (registeredOrigin !== undefined) return async () => {}; - await removeStaleEveDevServerLock(lockPath); - if (Date.now() > deadline) { - throw new Error( - `Timed out after ${DEV_SERVER_REGISTRY_TIMEOUT_MS}ms waiting for another Nuxt process to start eve.`, - ); - } - await delay(DEV_SERVER_REGISTRY_POLL_MS); - } - } -} - -function createEveBinaryPath(): string { - return join(resolvePackageRoot(), "bin", "eve.js"); -} - -function startServerProcess(input: { - readonly args: readonly string[]; - readonly command: string; - readonly cwd: string; - readonly env?: Record; -}): Promise { - return new Promise((resolvePromise, reject) => { - const child = spawn(input.command, input.args, { - cwd: input.cwd, - env: { ...process.env, ...input.env }, - stdio: ["ignore", "pipe", "pipe"], - }); - const timeout = setTimeout(() => { - child.kill(); - reject( - new Error( - `Timed out after ${DEFAULT_SERVER_READY_TIMEOUT_MS}ms waiting for eve to print its server URL.`, - ), - ); - }, DEFAULT_SERVER_READY_TIMEOUT_MS); - - const cleanup = () => { - clearTimeout(timeout); - child.off("error", handleError); - child.off("exit", handleEarlyExit); - }; - const handleError = (error: Error) => { - cleanup(); - reject(error); - }; - const handleEarlyExit = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - reject( - new Error( - `eve server process exited before printing its server URL (code ${String(code)}, signal ${String(signal)}).`, - ), - ); - }; - let resolved = false; - const handleOutput = (chunk: Buffer) => { - if (resolved) return; - const match = LOCAL_SERVER_URL_PATTERN.exec(chunk.toString("utf8")); - if (match === null) return; - resolved = true; - cleanup(); - resolvePromise({ origin: normalizeOrigin(match[0]), process: child }); - }; - - child.once("error", handleError); - child.once("exit", handleEarlyExit); - child.stdout.on("data", (chunk: Buffer) => { - process.stdout.write(chunk); - handleOutput(chunk); - }); - child.stderr.on("data", (chunk: Buffer) => { - process.stderr.write(chunk); - handleOutput(chunk); - }); +/** Resolves the root-scoped Eve development server used by Nuxt. */ +export function resolveSharedEveDevServer(appRoot: string): Promise { + return resolveSharedDevelopmentServer({ + appRoot, + timeoutMs: DEVELOPMENT_SERVER_TIMEOUT_MS, }); } - -function installProcessShutdown(handle: EveProcessHandle): EveProcessHandle { - const childProcess = handle.process; - if (childProcess === undefined) return handle; - const close = () => { - process.off("beforeExit", close); - process.off("exit", close); - if (!childProcess.killed) childProcess.kill(); - }; - process.once("beforeExit", close); - process.once("exit", close); - return handle; -} - -function startEveDevServer(appRoot: string): Promise { - return startServerProcess({ - args: [createEveBinaryPath(), "dev", "--no-ui", "--port", "0"], - command: process.execPath, - cwd: appRoot, - }).then((handle) => { - process.env[EVE_BASE_URL_ENV] = handle.origin; - return installProcessShutdown(handle); - }); -} - -/** - * Resolve a shared eve dev server for {@link appRoot}, reusing a healthy - * registered server when one exists and otherwise spawning a new one behind a - * cross-process lock so concurrent Nuxt processes don't each boot eve. - */ -export async function resolveSharedEveDevServer(appRoot: string): Promise { - const registeredOrigin = await readUsableEveDevServerRegistry(appRoot); - if (registeredOrigin !== undefined) { - process.env[EVE_BASE_URL_ENV] = registeredOrigin; - return { origin: registeredOrigin }; - } - - const releaseLock = await acquireEveDevServerLock(appRoot); - try { - const lockedRegisteredOrigin = await readUsableEveDevServerRegistry(appRoot); - if (lockedRegisteredOrigin !== undefined) { - process.env[EVE_BASE_URL_ENV] = lockedRegisteredOrigin; - return { origin: lockedRegisteredOrigin }; - } - const handle = await startEveDevServer(appRoot); - await writeEveDevServerRegistry(appRoot, handle); - return handle; - } finally { - await releaseLock(); - } -} diff --git a/packages/eve/src/public/nuxt/module.ts b/packages/eve/src/public/nuxt/module.ts index 105b833b7..2fd19ca25 100644 --- a/packages/eve/src/public/nuxt/module.ts +++ b/packages/eve/src/public/nuxt/module.ts @@ -1,4 +1,3 @@ -import type { ChildProcess } from "node:child_process"; import { isAbsolute, resolve } from "node:path"; import { addImports, defineNuxtModule, extendRouteRules } from "@nuxt/kit"; @@ -80,13 +79,13 @@ interface NitroVercelConfigHost { * the Vercel private service or a configured origin/port. * * When a dev server is spawned by this process, `onDevServerSpawned` is invoked - * with the child handle so the caller can wire lifecycle-scoped cleanup. + * with its teardown capability so the caller can wire lifecycle-scoped cleanup. */ async function resolveEveProxyTarget(input: { readonly appRoot: string; readonly dev: boolean; readonly servicePrefix: string; - readonly onDevServerSpawned?: (child: ChildProcess) => void; + readonly onDevServerSpawned?: (close: () => Promise) => void; }): Promise { if (!input.dev) { return resolveProductionTarget(input.servicePrefix); @@ -98,8 +97,8 @@ async function resolveEveProxyTarget(input: { } const handle = await resolveSharedEveDevServer(input.appRoot); - if (handle.process !== undefined) { - input.onDevServerSpawned?.(handle.process); + if (handle.close !== undefined) { + input.onDevServerSpawned?.(handle.close); } return joinRoutePrefix(handle.origin, EVE_ROUTE_PREFIX); @@ -160,16 +159,12 @@ export default defineNuxtModule({ appRoot, dev: nuxt.options.dev, servicePrefix, - onDevServerSpawned: (child) => { + onDevServerSpawned: (close) => { // Prefer Nuxt's lifecycle for cleanup so the dev server is torn // down on graceful shutdown and dev restarts. The process-exit - // guard in dev-server.ts remains as a fallback for non-graceful - // exits. - nuxt.hook("close", () => { - if (!child.killed) { - child.kill(); - } - }); + // guard in the shared resolver remains as a fallback for + // non-graceful exits. + nuxt.hook("close", close); }, }); diff --git a/packages/eve/src/public/sveltekit/dev-server.integration.test.ts b/packages/eve/src/public/sveltekit/dev-server.integration.test.ts index 231b21ba6..e6a725808 100644 --- a/packages/eve/src/public/sveltekit/dev-server.integration.test.ts +++ b/packages/eve/src/public/sveltekit/dev-server.integration.test.ts @@ -1,24 +1,24 @@ -import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { DevelopmentServerState } from "#internal/nitro/host/dev-server-state.js"; + import { EVE_BASE_URL_ENV, resolveSharedEveDevServer } from "./dev-server.js"; async function createTempAppRoot(): Promise { - return await mkdtemp(join(tmpdir(), "eve-sveltekit-dev-server-")); + const appRoot = await mkdtemp(join(tmpdir(), "eve-sveltekit-dev-server-")); + await writeFile(join(appRoot, "instructions.md"), "You are a test agent.\n"); + return appRoot; } -async function writeRegistry(appRoot: string, registry: Record): Promise { - await mkdir(join(appRoot, ".eve"), { recursive: true }); - await writeFile( - join(appRoot, ".eve", "sveltekit-dev-server.json"), - `${JSON.stringify(registry, null, 2)}\n`, - ); +async function publishReadyServer(appRoot: string, origin: string): Promise { + await new DevelopmentServerState({ appRoot }).write(origin); } -afterEach(() => { +afterEach(async () => { vi.unstubAllEnvs(); vi.unstubAllGlobals(); delete process.env[EVE_BASE_URL_ENV]; @@ -30,20 +30,20 @@ describe("resolveSharedEveDevServer", () => { const fetchMock = vi.fn(async () => new Response(null, { status: 200 })); vi.stubGlobal("fetch", fetchMock); - await writeRegistry(appRoot, { - appRoot, - origin: "http://127.0.0.1:49152", - pid: null, - updatedAt: new Date().toISOString(), - }); - - const handle = await resolveSharedEveDevServer(appRoot); - - expect(handle).toEqual({ origin: "http://127.0.0.1:49152" }); - expect(handle.process).toBeUndefined(); - expect(process.env[EVE_BASE_URL_ENV]).toBe("http://127.0.0.1:49152"); - expect(fetchMock).toHaveBeenCalledWith("http://127.0.0.1:49152/eve/v1/health", { - signal: expect.any(AbortSignal), - }); + await publishReadyServer(appRoot, "http://127.0.0.1:49152"); + + try { + const handle = await resolveSharedEveDevServer(appRoot); + + expect(handle).toEqual({ origin: "http://127.0.0.1:49152" }); + expect(handle.process).toBeUndefined(); + expect(process.env[EVE_BASE_URL_ENV]).toBeUndefined(); + expect(fetchMock).toHaveBeenCalledWith("http://127.0.0.1:49152/eve/v1/health", { + redirect: "error", + signal: expect.any(AbortSignal), + }); + } finally { + await rm(appRoot, { force: true, recursive: true }); + } }); }); diff --git a/packages/eve/src/public/sveltekit/dev-server.test.ts b/packages/eve/src/public/sveltekit/dev-server.test.ts deleted file mode 100644 index 4f2ad69de..000000000 --- a/packages/eve/src/public/sveltekit/dev-server.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { normalizeDevServerRegistry } from "./dev-server.js"; - -describe("normalizeDevServerRegistry", () => { - it("normalizes a well-formed record and canonicalizes the origin", () => { - expect( - normalizeDevServerRegistry({ - appRoot: "/app", - origin: "http://127.0.0.1:49152/", - pid: 1234, - updatedAt: "2026-05-28T00:00:00.000Z", - }), - ).toEqual({ - appRoot: "/app", - origin: "http://127.0.0.1:49152", - pid: 1234, - updatedAt: "2026-05-28T00:00:00.000Z", - }); - }); - - it("accepts a null pid", () => { - expect( - normalizeDevServerRegistry({ - appRoot: "/app", - origin: "http://127.0.0.1:49152", - pid: null, - updatedAt: "2026-05-28T00:00:00.000Z", - })?.pid, - ).toBeNull(); - }); - - const invalidCases: readonly { readonly label: string; readonly value: unknown }[] = [ - { label: "a scalar", value: "not a record" }, - { label: "null", value: null }, - { label: "an array", value: ["array"] }, - { label: "missing appRoot", value: { origin: "http://x", pid: null, updatedAt: "now" } }, - { label: "missing origin", value: { appRoot: "/app", pid: null, updatedAt: "now" } }, - { label: "missing updatedAt", value: { appRoot: "/app", origin: "http://x", pid: null } }, - { - label: "a non-number pid", - value: { appRoot: "/app", origin: "http://x", pid: "1", updatedAt: "now" }, - }, - { - label: "an invalid origin", - value: { appRoot: "/app", origin: "not a url", pid: null, updatedAt: "now" }, - }, - ]; - - it.each(invalidCases)("returns undefined for $label", ({ value }) => { - expect(normalizeDevServerRegistry(value)).toBeUndefined(); - }); -}); diff --git a/packages/eve/src/public/sveltekit/dev-server.ts b/packages/eve/src/public/sveltekit/dev-server.ts index 3c8c2376c..50f290c2f 100644 --- a/packages/eve/src/public/sveltekit/dev-server.ts +++ b/packages/eve/src/public/sveltekit/dev-server.ts @@ -1,283 +1,27 @@ -import { spawn, type ChildProcess } from "node:child_process"; -import { mkdir, open, readFile, rm, stat, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import type { ChildProcess } from "node:child_process"; -import { resolvePackageRoot } from "#internal/application/package.js"; -import { EVE_ROUTE_PREFIX } from "#protocol/routes.js"; +import { + EVE_BASE_URL_ENV, + resolveSharedDevelopmentServer, +} from "#internal/nitro/host/resolve-shared-development-server.js"; -import { joinRoutePrefix, normalizeOrigin } from "./routing.js"; - -export const EVE_BASE_URL_ENV = "EVE_BASE_URL"; - -const DEFAULT_SERVER_READY_TIMEOUT_MS = 30_000; -const DEV_SERVER_REGISTRY_TIMEOUT_MS = 30_000; -const DEV_SERVER_REGISTRY_POLL_MS = 100; -const DEV_SERVER_STALE_LOCK_MS = 30_000; -const EVE_CACHE_DIRECTORY_NAME = ".eve"; -const EVE_SVELTEKIT_DEV_SERVER_FILE_NAME = "sveltekit-dev-server.json"; -const EVE_SVELTEKIT_DEV_SERVER_LOCK_FILE_NAME = "sveltekit-dev-server.lock"; -const LOCAL_SERVER_URL_PATTERN = /https?:\/\/(?:\[[^\]\s]+\]|[^\s/:[\]]+)(?::\d+)?/; +export { EVE_BASE_URL_ENV }; export interface EveProcessHandle { readonly origin: string; readonly process?: ChildProcess; } -export interface EveDevServerRegistry { - readonly appRoot: string; - readonly origin: string; - readonly pid: number | null; - readonly updatedAt: string; -} - -function isNodeErrorWithCode(error: unknown, code: string): boolean { - return error instanceof Error && "code" in error && error.code === code; -} - -function delay(ms: number): Promise { - return new Promise((r) => setTimeout(r, ms)); -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -function resolveEveCacheDirectory(appRoot: string): string { - return join(appRoot, EVE_CACHE_DIRECTORY_NAME); -} - -function resolveEveDevServerRegistryPath(appRoot: string): string { - return join(resolveEveCacheDirectory(appRoot), EVE_SVELTEKIT_DEV_SERVER_FILE_NAME); -} - -function resolveEveDevServerLockPath(appRoot: string): string { - return join(resolveEveCacheDirectory(appRoot), EVE_SVELTEKIT_DEV_SERVER_LOCK_FILE_NAME); +interface EveDevelopmentServerHandle extends EveProcessHandle { + readonly close?: () => Promise; } -/** - * Parse and validate a persisted dev-server registry record. Returns - * `undefined` for anything that is not a well-formed registry so callers fall - * back to spawning a fresh server. - */ -export function normalizeDevServerRegistry(value: unknown): EveDevServerRegistry | undefined { - if (!isRecord(value)) return undefined; - if ( - typeof value.appRoot !== "string" || - typeof value.origin !== "string" || - typeof value.updatedAt !== "string" - ) - return undefined; - if (value.pid !== null && typeof value.pid !== "number") return undefined; - try { - return { - appRoot: value.appRoot, - origin: normalizeOrigin(value.origin), - pid: value.pid, - updatedAt: value.updatedAt, - }; - } catch { - return undefined; - } -} - -async function isEveServerHealthy(origin: string): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 1_000); - try { - const response = await fetch(joinRoutePrefix(origin, `${EVE_ROUTE_PREFIX}/health`), { - signal: controller.signal, - }); - return response.ok; - } catch { - return false; - } finally { - clearTimeout(timeout); - } -} - -async function readUsableEveDevServerRegistry(appRoot: string): Promise { - try { - const registry = normalizeDevServerRegistry( - JSON.parse(await readFile(resolveEveDevServerRegistryPath(appRoot), "utf8")) as unknown, - ); - if (registry === undefined || registry.appRoot !== appRoot) return undefined; - if (!(await isEveServerHealthy(registry.origin))) return undefined; - return registry.origin; - } catch (error) { - if (isNodeErrorWithCode(error, "ENOENT")) return undefined; - throw error; - } -} +const DEVELOPMENT_SERVER_TIMEOUT_MS = 30_000; -async function writeEveDevServerRegistry(appRoot: string, handle: EveProcessHandle): Promise { - await mkdir(resolveEveCacheDirectory(appRoot), { recursive: true }); - await writeFile( - resolveEveDevServerRegistryPath(appRoot), - `${JSON.stringify( - { - appRoot, - origin: handle.origin, - pid: handle.process?.pid ?? null, - updatedAt: new Date().toISOString(), - } satisfies EveDevServerRegistry, - null, - 2, - )}\n`, - ); -} - -async function removeStaleEveDevServerLock(lockPath: string): Promise { - try { - const lockStat = await stat(lockPath); - if (Date.now() - lockStat.mtimeMs > DEV_SERVER_STALE_LOCK_MS) { - await rm(lockPath, { force: true }); - } - } catch (error) { - if (!isNodeErrorWithCode(error, "ENOENT")) throw error; - } -} - -async function acquireEveDevServerLock(appRoot: string): Promise<() => Promise> { - const cacheDirectory = resolveEveCacheDirectory(appRoot); - const lockPath = resolveEveDevServerLockPath(appRoot); - const deadline = Date.now() + DEV_SERVER_REGISTRY_TIMEOUT_MS; - await mkdir(cacheDirectory, { recursive: true }); - - while (true) { - try { - const lockFile = await open(lockPath, "wx"); - await lockFile.writeFile(`${String(process.pid)}\n`); - await lockFile.close(); - return async () => { - await rm(lockPath, { force: true }); - }; - } catch (error) { - if (!isNodeErrorWithCode(error, "EEXIST")) throw error; - const registeredOrigin = await readUsableEveDevServerRegistry(appRoot); - if (registeredOrigin !== undefined) return async () => {}; - await removeStaleEveDevServerLock(lockPath); - if (Date.now() > deadline) { - throw new Error( - `Timed out after ${DEV_SERVER_REGISTRY_TIMEOUT_MS}ms waiting for another SvelteKit process to start eve.`, - ); - } - await delay(DEV_SERVER_REGISTRY_POLL_MS); - } - } -} - -function createEveBinaryPath(): string { - return join(resolvePackageRoot(), "bin", "eve.js"); -} - -function startServerProcess(input: { - readonly args: readonly string[]; - readonly command: string; - readonly cwd: string; - readonly env?: Record; -}): Promise { - return new Promise((resolvePromise, reject) => { - const child = spawn(input.command, input.args, { - cwd: input.cwd, - env: { ...process.env, ...input.env }, - stdio: ["ignore", "pipe", "pipe"], - }); - const timeout = setTimeout(() => { - child.kill(); - reject( - new Error( - `Timed out after ${DEFAULT_SERVER_READY_TIMEOUT_MS}ms waiting for eve to print its server URL.`, - ), - ); - }, DEFAULT_SERVER_READY_TIMEOUT_MS); - - const cleanup = () => { - clearTimeout(timeout); - child.off("error", handleError); - child.off("exit", handleEarlyExit); - }; - const handleError = (error: Error) => { - cleanup(); - reject(error); - }; - const handleEarlyExit = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - reject( - new Error( - `eve server process exited before printing its server URL (code ${String(code)}, signal ${String(signal)}).`, - ), - ); - }; - let resolved = false; - const handleOutput = (chunk: Buffer) => { - if (resolved) return; - const match = LOCAL_SERVER_URL_PATTERN.exec(chunk.toString("utf8")); - if (match === null) return; - resolved = true; - cleanup(); - resolvePromise({ origin: normalizeOrigin(match[0]), process: child }); - }; - - child.once("error", handleError); - child.once("exit", handleEarlyExit); - child.stdout.on("data", (chunk: Buffer) => { - process.stdout.write(chunk); - handleOutput(chunk); - }); - child.stderr.on("data", (chunk: Buffer) => { - process.stderr.write(chunk); - handleOutput(chunk); - }); +/** Resolves the root-scoped Eve development server used by SvelteKit. */ +export function resolveSharedEveDevServer(appRoot: string): Promise { + return resolveSharedDevelopmentServer({ + appRoot, + timeoutMs: DEVELOPMENT_SERVER_TIMEOUT_MS, }); } - -function installProcessShutdown(handle: EveProcessHandle): EveProcessHandle { - const childProcess = handle.process; - if (childProcess === undefined) return handle; - const close = () => { - process.off("beforeExit", close); - process.off("exit", close); - if (!childProcess.killed) childProcess.kill(); - }; - process.once("beforeExit", close); - process.once("exit", close); - return handle; -} - -function startEveDevServer(appRoot: string): Promise { - return startServerProcess({ - args: [createEveBinaryPath(), "dev", "--no-ui", "--port", "0"], - command: process.execPath, - cwd: appRoot, - }).then((handle) => { - process.env[EVE_BASE_URL_ENV] = handle.origin; - return installProcessShutdown(handle); - }); -} - -/** - * Resolve a shared eve dev server for {@link appRoot}, reusing a healthy - * registered server when one exists and otherwise spawning a new one behind a - * cross-process lock so concurrent SvelteKit processes don't each boot eve. - */ -export async function resolveSharedEveDevServer(appRoot: string): Promise { - const registeredOrigin = await readUsableEveDevServerRegistry(appRoot); - if (registeredOrigin !== undefined) { - process.env[EVE_BASE_URL_ENV] = registeredOrigin; - return { origin: registeredOrigin }; - } - - const releaseLock = await acquireEveDevServerLock(appRoot); - try { - const lockedRegisteredOrigin = await readUsableEveDevServerRegistry(appRoot); - if (lockedRegisteredOrigin !== undefined) { - process.env[EVE_BASE_URL_ENV] = lockedRegisteredOrigin; - return { origin: lockedRegisteredOrigin }; - } - const handle = await startEveDevServer(appRoot); - await writeEveDevServerRegistry(appRoot, handle); - return handle; - } finally { - await releaseLock(); - } -} diff --git a/packages/eve/src/public/sveltekit/index.test.ts b/packages/eve/src/public/sveltekit/index.test.ts index 7c7f42fab..276c872d2 100644 --- a/packages/eve/src/public/sveltekit/index.test.ts +++ b/packages/eve/src/public/sveltekit/index.test.ts @@ -28,6 +28,13 @@ function getConfigHook(plugin: Plugin): ConfigHook { return plugin.config as ConfigHook; } +function getCloseBundleHook(plugin: Plugin): () => unknown { + if (typeof plugin.closeBundle !== "function") { + throw new Error("expected plugin closeBundle hook"); + } + return plugin.closeBundle as () => unknown; +} + afterEach(() => { vi.clearAllMocks(); vi.unstubAllEnvs(); @@ -88,6 +95,20 @@ describe("eveSvelteKit", () => { }); }); + it("closes a development server spawned by this plugin", async () => { + const close = vi.fn(async () => {}); + resolveSharedEveDevServerMock.mockResolvedValueOnce({ + close, + origin: "http://127.0.0.1:49152", + }); + const plugin = eveSvelteKit(); + + await getConfigHook(plugin)({}, { command: "serve", mode: "development" }); + await getCloseBundleHook(plugin)(); + + expect(close).toHaveBeenCalledOnce(); + }); + it("prefers EVE_BASE_URL over spawning a shared server", async () => { vi.stubEnv("EVE_BASE_URL", "https://agent.example.com/root"); const plugin = eveSvelteKit(); diff --git a/packages/eve/src/public/sveltekit/index.ts b/packages/eve/src/public/sveltekit/index.ts index 4ed2464c5..faeba0601 100644 --- a/packages/eve/src/public/sveltekit/index.ts +++ b/packages/eve/src/public/sveltekit/index.ts @@ -61,13 +61,20 @@ function mergeProxyConfig( }; } -async function resolveEveDevProxyTarget(appRoot: string): Promise { +async function resolveEveDevProxyTarget( + appRoot: string, + onDevServerSpawned: (close: () => Promise) => void, +): Promise { const configuredEveBaseUrl = process.env[EVE_BASE_URL_ENV]?.trim(); if (configuredEveBaseUrl && configuredEveBaseUrl.length > 0) { return normalizeOrigin(configuredEveBaseUrl); } - return (await resolveSharedEveDevServer(appRoot)).origin; + const handle = await resolveSharedEveDevServer(appRoot); + if (handle.close !== undefined) { + onDevServerSpawned(handle.close); + } + return handle.origin; } /** @@ -85,6 +92,7 @@ async function resolveEveDevProxyTarget(appRoot: string): Promise { export function eveSvelteKit(options: EveSvelteKitPluginOptions = {}): Plugin { let svelteKitRoot = process.cwd(); let appRoot = resolveApplicationRoot(svelteKitRoot, options.eveRoot); + let closeDevelopmentServer: (() => Promise) | undefined; const servicePrefix = normalizeRoutePrefix(options.servicePrefix ?? EVE_SVELTEKIT_SERVICE_PREFIX); const shouldConfigureVercelJson = options.configureVercelJson !== false; @@ -108,7 +116,9 @@ export function eveSvelteKit(options: EveSvelteKitPluginOptions = {}): Plugin { return {}; } - const proxyTarget = await resolveEveDevProxyTarget(appRoot); + const proxyTarget = await resolveEveDevProxyTarget(appRoot, (close) => { + closeDevelopmentServer = close; + }); if (env.isPreview) { return { @@ -124,5 +134,9 @@ export function eveSvelteKit(options: EveSvelteKitPluginOptions = {}): Plugin { }, }; }, + async closeBundle() { + await closeDevelopmentServer?.(); + closeDevelopmentServer = undefined; + }, }; }