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
759 changes: 561 additions & 198 deletions packages/cli/src/commands/preview.ts

Large diffs are not rendered by default.

206 changes: 206 additions & 0 deletions packages/cli/src/utils/studioSelectionClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { describe, expect, it, vi } from "vitest";
import { resolve } from "node:path";
import {
AmbiguousPreviewServerError,
fetchStudioLint,
fetchStudioSelection,
studioApiUrl,
findPreviewServerForProject,
PreviewServerPortMismatchError,
studioSelectionUrl,
} from "./studioSelectionClient";
import type { ActiveServer } from "../server/portUtils";

const servers: ActiveServer[] = [
{
port: 3002,
projectName: "other",
projectDir: "/tmp/other",
version: "0.7.17",
pid: null,
},
{
port: 3003,
projectName: "demo project",
projectDir: "/tmp/demo",
version: "0.7.17",
pid: "123",
},
];

function mockProjectsFetch(port = 5190): typeof fetch {
return vi.fn(async (url: string | URL | Request) => {
expect(String(url)).toBe(`http://127.0.0.1:${port}/api/projects`);
return new Response(
JSON.stringify({
projects: [{ id: "demo project", dir: "/tmp/demo", title: "Demo" }],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}) as unknown as typeof fetch;
}

describe("studioSelectionClient", () => {
it("finds the active preview server for a project directory", async () => {
const scan = vi.fn(async () => servers);

const server = await findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan);

expect(server?.port).toBe(3003);
expect(scan).toHaveBeenCalledWith(3002);
});

it("matches by project directory when multiple projects are open", async () => {
const scan = vi.fn(async () => [
...servers,
{
port: 3004,
projectName: "third",
projectDir: "/tmp/third",
version: "0.7.17",
pid: null,
},
]);

const server = await findPreviewServerForProject(resolve("/tmp/third"), 3002, scan);

expect(server?.port).toBe(3004);
});

it("rejects ambiguous duplicate servers for the same project", async () => {
const scan = vi.fn(async () => [servers[1]!, { ...servers[1]!, port: 3004, pid: "456" }]);

await expect(
findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan),
).rejects.toMatchObject({
name: "AmbiguousPreviewServerError",
ports: [3003, 3004],
} satisfies Partial<AmbiguousPreviewServerError>);
});

it("uses an explicit preferred port to disambiguate duplicate project servers", async () => {
const scan = vi.fn(async () => [servers[1]!, { ...servers[1]!, port: 3004, pid: "456" }]);

const server = await findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan, undefined, {
preferredPort: 3004,
});

expect(server?.port).toBe(3004);
});

it("rejects an explicit preferred port that does not match the only project server", async () => {
const scan = vi.fn(async () => [servers[1]!]);
const fetchImpl = vi.fn(async () => new Response("missing", { status: 404 }));

await expect(
findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan, fetchImpl, {
preferredPort: 3999,
}),
).rejects.toMatchObject({
name: "PreviewServerPortMismatchError",
requestedPort: 3999,
ports: [3003],
} satisfies Partial<PreviewServerPortMismatchError>);
expect(fetchImpl).toHaveBeenCalledWith("http://127.0.0.1:3999/api/projects");
});

it("falls back to Vite Studio project discovery on port 5190", async () => {
const scan = vi.fn(async () => []);
const fetchImpl = mockProjectsFetch();

const server = await findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan, fetchImpl);

expect(server).toEqual({
port: 5190,
projectName: "demo project",
projectDir: "/tmp/demo",
version: "studio-dev",
pid: null,
});
});

it("checks an explicit preferred port for Vite Studio discovery", async () => {
const scan = vi.fn(async () => []);
const fetchImpl = mockProjectsFetch(5191);

const server = await findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan, fetchImpl, {
preferredPort: 5191,
});

expect(server?.port).toBe(5191);
expect(fetchImpl).not.toHaveBeenCalledWith("http://127.0.0.1:5190/api/projects");
});

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",
);
});

it("builds URLs to other preview server API routes", () => {
expect(studioApiUrl(servers[1]!, "lint")).toBe(
"http://127.0.0.1:3003/api/projects/demo%20project/lint",
);
});

it("fetches the current selection snapshot from a preview server", async () => {
const fetchImpl = vi.fn(async () => {
return new Response(
JSON.stringify({
selection: {
schemaVersion: 1,
projectId: "demo project",
compositionPath: "index.html",
sourceFile: "index.html",
currentTime: 2,
target: { hfId: "cta" },
label: "CTA",
tagName: "button",
boundingBox: { x: 0, y: 0, width: 10, height: 10 },
textContent: "Go",
dataAttributes: {},
inlineStyles: {},
computedStyles: {},
textFields: [],
capabilities: { canSelect: true },
thumbnailUrl: "/api/projects/demo%20project/thumbnail/index.html?t=2&format=png",
},
updatedAt: "2026-06-28T16:00:00.000Z",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
});

const result = await fetchStudioSelection(servers[1]!, fetchImpl);

expect(result.selection?.target.hfId).toBe("cta");
expect(result.updatedAt).toBe("2026-06-28T16:00:00.000Z");
expect(fetchImpl).toHaveBeenCalledWith(studioSelectionUrl(servers[1]!));
});

it("throws when the preview server returns a failed response", async () => {
await expect(
fetchStudioSelection(
servers[1]!,
vi.fn(async () => new Response("missing", { status: 404 })),
),
).rejects.toThrow("selection endpoint returned 404");
});

it("fetches lint findings from a preview server", async () => {
const fetchImpl = vi.fn(async () => {
return new Response(
JSON.stringify({
findings: [{ severity: "error", message: "Missing timeline", file: "index.html" }],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
});

const result = await fetchStudioLint(servers[1]!, fetchImpl);

expect(result.findings).toHaveLength(1);
expect(result.findings[0]?.message).toBe("Missing timeline");
expect(fetchImpl).toHaveBeenCalledWith(studioApiUrl(servers[1]!, "lint"));
});
});
149 changes: 149 additions & 0 deletions packages/cli/src/utils/studioSelectionClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { existsSync, realpathSync } from "node:fs";
import { resolve } from "node:path";
import { scanActiveServers, type ActiveServer } from "../server/portUtils.js";
import type {
LintResult,
ResolvedProject,
StudioSelectionResponse,
} from "@hyperframes/studio-server";

export type StudioLintResponse = LintResult;

const VITE_STUDIO_DISCOVERY_PORTS = [5190] as const;

interface StudioProjectsResponse {
projects?: ResolvedProject[];
}

interface FindPreviewServerOptions {
preferredPort?: number;
}

export class AmbiguousPreviewServerError extends Error {
readonly ports: number[];

constructor(servers: ActiveServer[]) {
const ports = servers.map((server) => server.port).sort((a, b) => a - b);
super(
`Multiple Studio preview servers match this project (${ports.join(", ")}). Pass --port <port> to choose one.`,
);
this.name = "AmbiguousPreviewServerError";
this.ports = ports;
}
}

export class PreviewServerPortMismatchError extends Error {
readonly requestedPort: number;
readonly ports: number[];

constructor(requestedPort: number, servers: ActiveServer[]) {
const ports = servers.map((server) => server.port).sort((a, b) => a - b);
super(
`No Studio preview server for this project is running on port ${requestedPort}. Matching server port${ports.length === 1 ? "" : "s"}: ${ports.join(", ")}. Rerun with --port ${ports[0]}${ports.length > 1 ? " or omit --port to see all candidates" : ""}.`,
);
this.name = "PreviewServerPortMismatchError";
this.requestedPort = requestedPort;
this.ports = ports;
}
}

function normalizePath(path: string): string {
const resolved = resolve(path);
try {
if (existsSync(resolved)) {
return realpathSync(resolved).replace(/\\/g, "/").toLowerCase();
}
} catch {
// Fall through to resolved-path normalization.
}
return resolved.replace(/\\/g, "/").toLowerCase();
}

export async function findPreviewServerForProject(
projectDir: string,
startPort = 3002,
scan: (startPort?: number) => Promise<ActiveServer[]> = scanActiveServers,
fetchImpl: typeof fetch = fetch,
options: FindPreviewServerOptions = {},
): Promise<ActiveServer | null> {
const normalizedProjectDir = normalizePath(projectDir);
const servers = await scan(startPort);
const embeddedServers = servers.filter(
(server) => normalizePath(server.projectDir) === normalizedProjectDir,
);
if (options.preferredPort !== undefined) {
const preferred = embeddedServers.find((server) => server.port === options.preferredPort);
if (preferred) return preferred;
const viteServer = await findViteStudioServerForProject(normalizedProjectDir, fetchImpl, [
options.preferredPort,
]);
if (viteServer) return viteServer;
if (embeddedServers.length > 0) {
throw new PreviewServerPortMismatchError(options.preferredPort, embeddedServers);
}
return null;
}
if (embeddedServers.length === 1) return embeddedServers[0]!;
if (embeddedServers.length > 1) throw new AmbiguousPreviewServerError(embeddedServers);
return findViteStudioServerForProject(normalizedProjectDir, fetchImpl);
}

export function studioSelectionUrl(server: ActiveServer): string {
return studioApiUrl(server, "selection");
}

export function studioApiUrl(server: ActiveServer, route: string): string {
return `http://127.0.0.1:${server.port}/api/projects/${encodeURIComponent(server.projectName)}/${route}`;
}

async function findViteStudioServerForProject(
normalizedProjectDir: string,
fetchImpl: typeof fetch,
ports: readonly number[] = VITE_STUDIO_DISCOVERY_PORTS,
): Promise<ActiveServer | null> {
for (const port of ports) {
try {
const response = await fetchImpl(`http://127.0.0.1:${port}/api/projects`);
if (!response.ok) continue;
const payload = (await response.json()) as StudioProjectsResponse;
const project = payload.projects?.find(
(candidate) => normalizePath(candidate.dir) === normalizedProjectDir,
);
if (!project) continue;
return {
port,
projectName: project.id,
projectDir: project.dir,
version: "studio-dev",
pid: null,
};
} catch {
// Port is not a Vite-served Studio, or the dev server is not reachable.
}
}
return null;
}

export async function fetchStudioSelection(
server: ActiveServer,
fetchImpl: typeof fetch = fetch,
): Promise<StudioSelectionResponse> {
const url = studioSelectionUrl(server);
const response = await fetchImpl(url);
if (!response.ok) {
throw new Error(`selection endpoint returned ${response.status}`);
}
return (await response.json()) as StudioSelectionResponse;
}

export async function fetchStudioLint(
server: ActiveServer,
fetchImpl: typeof fetch = fetch,
): Promise<StudioLintResponse> {
const url = studioApiUrl(server, "lint");
const response = await fetchImpl(url);
if (!response.ok) {
throw new Error(`lint endpoint returned ${response.status}`);
}
return (await response.json()) as StudioLintResponse;
}
2 changes: 2 additions & 0 deletions packages/studio-server/src/createStudioApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { registerThumbnailRoutes } from "./routes/thumbnail.js";
import { registerWaveformRoutes } from "./routes/waveform.js";
import { registerFontRoutes } from "./routes/fonts.js";
import { registerRegistryRoutes } from "./routes/registry.js";
import { registerSelectionRoutes } from "./routes/selection.js";

/**
* Create a Hono sub-app with all studio API routes.
Expand All @@ -27,6 +28,7 @@ export function createStudioApi(adapter: StudioApiAdapter): Hono {
registerLintRoutes(api, adapter);
registerRenderRoutes(api, adapter);
registerThumbnailRoutes(api, adapter);
registerSelectionRoutes(api, adapter);
registerWaveformRoutes(api, adapter);
registerFontRoutes(api);
registerRegistryRoutes(api, adapter);
Expand Down
10 changes: 9 additions & 1 deletion packages/studio-server/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
export { createStudioApi } from "./createStudioApi.js";
export { createProjectSignature } from "./helpers/projectSignature.js";
export type { StudioApiAdapter, ResolvedProject, RenderJobState, LintResult } from "./types.js";
export type {
StudioApiAdapter,
ResolvedProject,
RenderJobState,
LintResult,
StudioSelectionResponse,
StudioSelectionSnapshot,
StudioSelectionTextField,
} from "./types.js";
export { isSafePath, walkDir } from "./helpers/safePath.js";
export { getMimeType, MIME_TYPES } from "./helpers/mime.js";
export { buildSubCompositionHtml } from "./helpers/subComposition.js";
Expand Down
Loading
Loading