Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/cli/src/utils/studioSelectionClient.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { resolve } from "node:path";
import {
AmbiguousPreviewServerError,
fetchStudioLint,
fetchLoopback,
fetchStudioSelection,
studioApiUrl,
findPreviewServerForProject,
Expand Down Expand Up @@ -161,6 +164,27 @@ describe("studioSelectionClient", () => {
expect(fetchImpl).not.toHaveBeenCalledWith("http://127.0.0.1:5190/api/projects");
});

it("fetches from a loopback server that accepts the connection", async () => {
const server = createServer((_, res) => res.end("ok"));
await new Promise<void>((done) => server.listen(0, "127.0.0.1", done));
const { port } = server.address() as AddressInfo;
try {
const response = await fetchLoopback(`http://127.0.0.1:${port}/api/projects`);
expect(await response.text()).toBe("ok");
} finally {
server.close();
}
});

it("fails without fetching when nothing accepts the connection", async () => {
const server = createServer();
await new Promise<void>((done) => server.listen(0, "127.0.0.1", done));
const { port } = server.address() as AddressInfo;
await new Promise<void>((done) => server.close(() => done()));

await expect(fetchLoopback(`http://127.0.0.1:${port}/api/projects`)).rejects.toThrow();
});

it("builds a URL to the existing preview server's selection endpoint", () => {
expect(studioSelectionUrl(servers[1]!)).toBe(
"http://127.0.0.1:3003/api/projects/demo%20project/selection",
Expand Down
34 changes: 32 additions & 2 deletions packages/cli/src/utils/studioSelectionClient.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { existsSync, realpathSync } from "node:fs";
import { connect } from "node:net";
import { resolve } from "node:path";
import { scanActiveServers, type ActiveServer } from "../server/portUtils.js";
import type {
Expand Down Expand Up @@ -59,11 +60,40 @@ function normalizePath(path: string): string {
return resolved.replace(/\\/g, "/").toLowerCase();
}

// A Studio on loopback accepts a connection in milliseconds.
const LOOPBACK_CONNECT_TIMEOUT_MS = 1000;

/**
* Fetches from a loopback Studio only once a bounded connect succeeds. Some
* hosts drop a connection to a closed port instead of refusing it (WSL
* mirrored networking, a firewall in stealth mode): fetch then waits for its
* 10 s connect timeout, and aborting it does not close the socket, so the
* command's process stays alive that long anyway. A destroyed socket does not.
*/
export async function fetchLoopback(url: string): Promise<Response> {
const { hostname, port } = new URL(url);
const host = hostname.replace(/^\[|\]$/g, "");
await new Promise<void>((accept, refuse) => {
const socket = connect({ host, port: Number(port) });
const close = (error?: Error) => {
socket.destroy();
if (error) refuse(error);
else accept();
};
socket.setTimeout(LOOPBACK_CONNECT_TIMEOUT_MS, () =>
close(new Error(`connect to ${host}:${port} timed out`)),
);
socket.once("connect", () => close());
socket.once("error", close);
});
return fetch(url);
}

export async function findPreviewServerForProject(
projectDir: string,
startPort = 3002,
scan: (startPort?: number) => Promise<ActiveServer[]> = scanActiveServers,
fetchImpl: typeof fetch = fetch,
fetchImpl: (url: string) => Promise<Response> = fetchLoopback,
options: FindPreviewServerOptions = {},
): Promise<ActiveServer | null> {
const normalizedProjectDir = normalizePath(projectDir);
Expand Down Expand Up @@ -104,7 +134,7 @@ const LOOPBACK_HOSTS = ["127.0.0.1", "[::1]"] as const;

async function findViteStudioServerForProject(
normalizedProjectDir: string,
fetchImpl: typeof fetch,
fetchImpl: (url: string) => Promise<Response>,
ports: readonly number[] = VITE_STUDIO_DISCOVERY_PORTS,
): Promise<ActiveServer | null> {
for (const port of ports) {
Expand Down
Loading