mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
feat(cli): expose Studio selection through preview (#1777)
Add a small Studio selection channel so agents can ask a running preview server for the element the user selected in Studio. This keeps the UX on the existing npx hyperframes preview surface while giving agents a stable source file, target selector, timeline time, and thumbnail URL for follow-up edits.
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
import { defineCommand } from "citty";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { spawn } from "node:child_process";
|
||||
import { spawn, type ChildProcessByStdio } from "node:child_process";
|
||||
import type { Readable } from "node:stream";
|
||||
|
||||
export const examples: Example[] = [
|
||||
["Preview the current project", "hyperframes preview"],
|
||||
["Print the current Studio selection as JSON", "hyperframes preview --selection --json"],
|
||||
["Print current Studio context as JSON", "hyperframes preview --context --json"],
|
||||
["Preview a specific project directory", "hyperframes preview ./my-video"],
|
||||
["Use a custom port", "hyperframes preview --port 8080"],
|
||||
["Force a new server even if one is already running", "hyperframes preview --force-new"],
|
||||
@@ -24,6 +27,7 @@ import * as clack from "@clack/prompts";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { isDevMode } from "../utils/env.js";
|
||||
import { buildNpxCommand } from "../utils/npxCommand.js";
|
||||
import type { StudioSelectionSnapshot } from "@hyperframes/studio-server";
|
||||
import {
|
||||
openBrowser,
|
||||
parseRemoteDebuggingPort,
|
||||
@@ -40,6 +44,40 @@ import {
|
||||
import { killOrphanedProcesses, killProcessTree } from "../utils/orphanCleanup.js";
|
||||
import { resolveProject } from "../utils/project.js";
|
||||
|
||||
interface BrowserLaunchOptions {
|
||||
noOpen?: boolean;
|
||||
browserPath?: string;
|
||||
userDataDir?: string;
|
||||
remoteDebuggingPort?: number;
|
||||
}
|
||||
|
||||
interface StudioLaunchOptions extends BrowserLaunchOptions {
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
interface EmbeddedStudioOptions extends StudioLaunchOptions {
|
||||
forceNew?: boolean;
|
||||
}
|
||||
|
||||
type StudioChildProcess = ChildProcessByStdio<null, Readable, Readable>;
|
||||
type ContextField = "server" | "selection" | "lint" | "capabilities";
|
||||
type CompactSelectionPayload = Pick<
|
||||
StudioSelectionSnapshot,
|
||||
| "schemaVersion"
|
||||
| "projectId"
|
||||
| "compositionPath"
|
||||
| "sourceFile"
|
||||
| "currentTime"
|
||||
| "target"
|
||||
| "label"
|
||||
| "tagName"
|
||||
| "boundingBox"
|
||||
| "textContent"
|
||||
| "thumbnailUrl"
|
||||
>;
|
||||
|
||||
const DEFAULT_CONTEXT_FIELDS: ContextField[] = ["server", "selection", "lint", "capabilities"];
|
||||
|
||||
export default defineCommand({
|
||||
meta: { name: "preview", description: "Start the studio for previewing compositions" },
|
||||
args: {
|
||||
@@ -65,6 +103,32 @@ export default defineCommand({
|
||||
default: true,
|
||||
description: "Open browser automatically",
|
||||
},
|
||||
selection: {
|
||||
type: "boolean",
|
||||
description: "Print the current element selected in a running Studio preview and exit",
|
||||
default: false,
|
||||
},
|
||||
json: {
|
||||
type: "boolean",
|
||||
description: "Output preview selection/context as JSON (only with --selection or --context)",
|
||||
default: false,
|
||||
},
|
||||
context: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Print the current agent-readable context from a running Studio preview and exit",
|
||||
default: false,
|
||||
},
|
||||
"context-fields": {
|
||||
type: "string",
|
||||
description:
|
||||
"Comma-separated context fields to include: server,selection,lint,capabilities (only with --context)",
|
||||
},
|
||||
"context-detail": {
|
||||
type: "string",
|
||||
description: "Context payload detail: compact or full (only with --context)",
|
||||
default: "compact",
|
||||
},
|
||||
"browser-path": {
|
||||
type: "string",
|
||||
description: "Path to the browser executable to open",
|
||||
@@ -80,6 +144,7 @@ export default defineCommand({
|
||||
},
|
||||
async run({ args }) {
|
||||
const startPort = parseInt(args.port ?? "3002", 10);
|
||||
const preferredContextPort = hasExplicitPreviewPort(process.argv) ? startPort : undefined;
|
||||
|
||||
// --list: scan and display active servers
|
||||
if (args.list) {
|
||||
@@ -111,6 +176,26 @@ export default defineCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.context) {
|
||||
const project = resolveProject(args.dir);
|
||||
return printCurrentContext(project.dir, startPort, {
|
||||
json: Boolean(args.json),
|
||||
fields: args["context-fields"] as string | undefined,
|
||||
detail: args["context-detail"] as string | undefined,
|
||||
...(preferredContextPort === undefined ? {} : { preferredPort: preferredContextPort }),
|
||||
});
|
||||
}
|
||||
|
||||
if (args.selection) {
|
||||
const project = resolveProject(args.dir);
|
||||
return printCurrentSelection(
|
||||
project.dir,
|
||||
startPort,
|
||||
Boolean(args.json),
|
||||
preferredContextPort,
|
||||
);
|
||||
}
|
||||
|
||||
// Kill orphaned chrome-headless-shell processes from previous crashed sessions.
|
||||
const orphansKilled = killOrphanedProcesses();
|
||||
if (orphansKilled > 0) {
|
||||
@@ -198,28 +283,390 @@ export default defineCommand({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Dev mode: spawn the studio dev server from the monorepo.
|
||||
*/
|
||||
async function runDevMode(
|
||||
dir: string,
|
||||
options?: {
|
||||
projectName?: string;
|
||||
noOpen?: boolean;
|
||||
browserPath?: string;
|
||||
userDataDir?: string;
|
||||
remoteDebuggingPort?: number;
|
||||
},
|
||||
function previewBaseUrl(port: number): string {
|
||||
return `http://127.0.0.1:${port}`;
|
||||
}
|
||||
|
||||
function absolutePreviewUrl(port: number, path: string): string {
|
||||
if (/^https?:\/\//.test(path)) return path;
|
||||
return `${previewBaseUrl(port)}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
function hasExplicitPreviewPort(argv: string[]): boolean {
|
||||
return argv.some((arg) => arg === "--port" || arg.startsWith("--port="));
|
||||
}
|
||||
|
||||
function printSelectionFailure(code: string, message: string, json: boolean): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify({ ok: false, error: { code, message } }, null, 2));
|
||||
} else {
|
||||
clack.log.error(message);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function previewServerPayload(server: { port: number; projectName: string; projectDir: string }): {
|
||||
port: number;
|
||||
projectName: string;
|
||||
projectDir: string;
|
||||
url: string;
|
||||
} {
|
||||
return {
|
||||
port: server.port,
|
||||
projectName: server.projectName,
|
||||
projectDir: server.projectDir,
|
||||
url: previewBaseUrl(server.port),
|
||||
};
|
||||
}
|
||||
|
||||
function parseContextFields(value: string | undefined): ContextField[] {
|
||||
if (value === undefined) return DEFAULT_CONTEXT_FIELDS;
|
||||
if (!value.trim()) throw new Error("--context-fields cannot be empty");
|
||||
const allowed = new Set<ContextField>(DEFAULT_CONTEXT_FIELDS);
|
||||
const fields = value
|
||||
.split(",")
|
||||
.map((field) => field.trim())
|
||||
.filter(Boolean);
|
||||
const invalid = fields.filter((field) => !allowed.has(field as ContextField));
|
||||
if (invalid.length > 0) {
|
||||
throw new Error(
|
||||
`Unknown context field${invalid.length === 1 ? "" : "s"}: ${invalid.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return [...new Set(fields)] as ContextField[];
|
||||
}
|
||||
|
||||
function contextIncludes(fields: ContextField[], field: ContextField): boolean {
|
||||
return fields.includes(field);
|
||||
}
|
||||
|
||||
function addContextError(
|
||||
payload: Record<string, unknown>,
|
||||
field: ContextField,
|
||||
error: { code: string; message: string },
|
||||
): void {
|
||||
payload.errors = {
|
||||
...((payload.errors as Record<string, unknown> | undefined) ?? {}),
|
||||
[field]: error,
|
||||
};
|
||||
}
|
||||
|
||||
async function printCurrentSelection(
|
||||
projectDir: string,
|
||||
startPort: number,
|
||||
json: boolean,
|
||||
preferredPort?: number,
|
||||
): Promise<void> {
|
||||
// Find monorepo root by navigating from packages/cli/src/commands/
|
||||
const thisFile = fileURLToPath(import.meta.url);
|
||||
const repoRoot = resolve(dirname(thisFile), "..", "..", "..", "..");
|
||||
const {
|
||||
AmbiguousPreviewServerError,
|
||||
PreviewServerPortMismatchError,
|
||||
fetchStudioSelection,
|
||||
findPreviewServerForProject,
|
||||
} = await import("../utils/studioSelectionClient.js");
|
||||
let server: Awaited<ReturnType<typeof findPreviewServerForProject>>;
|
||||
try {
|
||||
server = await findPreviewServerForProject(
|
||||
projectDir,
|
||||
startPort,
|
||||
undefined,
|
||||
undefined,
|
||||
preferredPort === undefined ? undefined : { preferredPort },
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof AmbiguousPreviewServerError) {
|
||||
printSelectionFailure("ambiguous-preview-server", err.message, json);
|
||||
return;
|
||||
}
|
||||
if (err instanceof PreviewServerPortMismatchError) {
|
||||
printSelectionFailure("preview-port-mismatch", err.message, json);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (!server) {
|
||||
printSelectionFailure(
|
||||
"preview-not-running",
|
||||
"No running Studio preview found for this project. Start one with: npx hyperframes preview",
|
||||
json,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Symlink project into the studio's data directory
|
||||
const projectsDir = join(repoRoot, "packages", "studio", "data", "projects");
|
||||
const pName = options?.projectName ?? basename(dir);
|
||||
const symlinkPath = join(projectsDir, pName);
|
||||
let response: Awaited<ReturnType<typeof fetchStudioSelection>>;
|
||||
try {
|
||||
response = await fetchStudioSelection(server);
|
||||
} catch (err) {
|
||||
printSelectionFailure("selection-unavailable", errorMessage(err), json);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.selection) {
|
||||
printSelectionFailure(
|
||||
"no-selection",
|
||||
"Studio is running, but no element is selected. Select an element in Studio and rerun this command.",
|
||||
json,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = {
|
||||
...response.selection,
|
||||
thumbnailUrl: absolutePreviewUrl(server.port, response.selection.thumbnailUrl),
|
||||
};
|
||||
|
||||
if (json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
server: previewServerPayload(server),
|
||||
selection,
|
||||
updatedAt: response.updatedAt,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${c.success("◇")} ${c.accent(selection.label)} selected in Studio`);
|
||||
console.log(` ${c.dim("Source")} ${selection.sourceFile}`);
|
||||
console.log(
|
||||
` ${c.dim("Target")} ${selection.target.hfId ?? selection.target.id ?? selection.target.selector ?? "(none)"}`,
|
||||
);
|
||||
console.log(` ${c.dim("Time")} ${selection.currentTime.toFixed(3)}s`);
|
||||
console.log(` ${c.dim("Thumbnail")} ${selection.thumbnailUrl}`);
|
||||
console.log();
|
||||
console.log(c.dim("Use --json for the full agent-readable selection payload."));
|
||||
}
|
||||
|
||||
function countLintFindings(findings: Array<{ severity: string }>): {
|
||||
errors: number;
|
||||
warnings: number;
|
||||
} {
|
||||
return {
|
||||
errors: findings.filter((finding) => finding.severity === "error").length,
|
||||
warnings: findings.filter((finding) => finding.severity === "warning").length,
|
||||
};
|
||||
}
|
||||
|
||||
async function printCurrentContext(
|
||||
projectDir: string,
|
||||
startPort: number,
|
||||
options: { json: boolean; fields?: string; detail?: string; preferredPort?: number },
|
||||
): Promise<void> {
|
||||
let fields: ContextField[];
|
||||
try {
|
||||
fields = parseContextFields(options.fields);
|
||||
} catch (err) {
|
||||
printSelectionFailure(
|
||||
"invalid-context-fields",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
options.json,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const fullDetail = options.detail === "full";
|
||||
if (options.detail !== undefined && !["compact", "full"].includes(options.detail)) {
|
||||
printSelectionFailure(
|
||||
"invalid-context-detail",
|
||||
"--context-detail must be compact or full",
|
||||
options.json,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
AmbiguousPreviewServerError,
|
||||
PreviewServerPortMismatchError,
|
||||
fetchStudioLint,
|
||||
fetchStudioSelection,
|
||||
findPreviewServerForProject,
|
||||
} = await import("../utils/studioSelectionClient.js");
|
||||
let server: Awaited<ReturnType<typeof findPreviewServerForProject>>;
|
||||
try {
|
||||
server = await findPreviewServerForProject(
|
||||
projectDir,
|
||||
startPort,
|
||||
undefined,
|
||||
undefined,
|
||||
options.preferredPort === undefined ? undefined : { preferredPort: options.preferredPort },
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof AmbiguousPreviewServerError) {
|
||||
printSelectionFailure("ambiguous-preview-server", err.message, options.json);
|
||||
return;
|
||||
}
|
||||
if (err instanceof PreviewServerPortMismatchError) {
|
||||
printSelectionFailure("preview-port-mismatch", err.message, options.json);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (!server) {
|
||||
printSelectionFailure(
|
||||
"preview-not-running",
|
||||
"No running Studio preview found for this project. Start one with: npx hyperframes preview",
|
||||
options.json,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const wantsSelection = contextIncludes(fields, "selection");
|
||||
const wantsLint = contextIncludes(fields, "lint");
|
||||
const [selectionResult, lintResult] = await Promise.allSettled([
|
||||
wantsSelection ? fetchStudioSelection(server) : Promise.resolve(null),
|
||||
wantsLint ? fetchStudioLint(server) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const selection =
|
||||
selectionResult.status === "fulfilled" && selectionResult.value?.selection
|
||||
? {
|
||||
ok: true as const,
|
||||
value: fullDetail
|
||||
? {
|
||||
...selectionResult.value.selection,
|
||||
thumbnailUrl: absolutePreviewUrl(
|
||||
server.port,
|
||||
selectionResult.value.selection.thumbnailUrl,
|
||||
),
|
||||
}
|
||||
: compactSelectionPayload({
|
||||
...selectionResult.value.selection,
|
||||
thumbnailUrl: absolutePreviewUrl(
|
||||
server.port,
|
||||
selectionResult.value.selection.thumbnailUrl,
|
||||
),
|
||||
}),
|
||||
updatedAt: selectionResult.value.updatedAt,
|
||||
}
|
||||
: {
|
||||
ok: false as const,
|
||||
error:
|
||||
selectionResult.status === "rejected"
|
||||
? { code: "selection-unavailable", message: errorMessage(selectionResult.reason) }
|
||||
: {
|
||||
code: "no-selection",
|
||||
message: "Studio is running, but no element is selected.",
|
||||
},
|
||||
};
|
||||
|
||||
const lint =
|
||||
lintResult.status === "fulfilled" && lintResult.value
|
||||
? {
|
||||
ok: true as const,
|
||||
summary: countLintFindings(lintResult.value.findings),
|
||||
findings: lintResult.value.findings,
|
||||
}
|
||||
: {
|
||||
ok: false as const,
|
||||
error:
|
||||
lintResult.status === "rejected"
|
||||
? { code: "lint-unavailable", message: errorMessage(lintResult.reason) }
|
||||
: { code: "lint-not-requested", message: "Lint was not requested." },
|
||||
};
|
||||
|
||||
const payload: Record<string, unknown> = { ok: true };
|
||||
if (contextIncludes(fields, "server")) payload.server = previewServerPayload(server);
|
||||
if (contextIncludes(fields, "selection")) {
|
||||
payload.selection = selection.ok ? selection.value : null;
|
||||
payload.selectionUpdatedAt = selection.ok ? selection.updatedAt : null;
|
||||
if (!selection.ok) addContextError(payload, "selection", selection.error);
|
||||
}
|
||||
if (contextIncludes(fields, "lint")) payload.lint = lint;
|
||||
if (contextIncludes(fields, "capabilities")) {
|
||||
payload.capabilities = {
|
||||
selection: true,
|
||||
lint: true,
|
||||
frame: false,
|
||||
visibleElements: false,
|
||||
lastAction: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${c.success("◇")} Studio context`);
|
||||
if (contextIncludes(fields, "server")) {
|
||||
console.log(` ${c.dim("Project")} ${server.projectName}`);
|
||||
console.log(` ${c.dim("Studio")} ${previewBaseUrl(server.port)}`);
|
||||
}
|
||||
if (contextIncludes(fields, "selection")) {
|
||||
if (selection.ok) {
|
||||
console.log(` ${c.dim("Selection")} ${selection.value.label}`);
|
||||
} else {
|
||||
console.log(` ${c.dim("Selection")} ${selection.error.message}`);
|
||||
}
|
||||
}
|
||||
if (contextIncludes(fields, "lint")) {
|
||||
if (lint.ok) {
|
||||
console.log(
|
||||
` ${c.dim("Lint")} ${lint.summary.errors} error(s), ${lint.summary.warnings} warning(s)`,
|
||||
);
|
||||
} else {
|
||||
console.log(` ${c.dim("Lint")} ${lint.error.message}`);
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
console.log(c.dim("Use --json for the full agent-readable context payload."));
|
||||
}
|
||||
|
||||
function compactSelectionPayload(selection: StudioSelectionSnapshot): CompactSelectionPayload {
|
||||
return {
|
||||
schemaVersion: selection.schemaVersion,
|
||||
projectId: selection.projectId,
|
||||
compositionPath: selection.compositionPath,
|
||||
sourceFile: selection.sourceFile,
|
||||
currentTime: selection.currentTime,
|
||||
target: selection.target,
|
||||
label: selection.label,
|
||||
tagName: selection.tagName,
|
||||
boundingBox: selection.boundingBox,
|
||||
textContent: selection.textContent,
|
||||
thumbnailUrl: selection.thumbnailUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function openStudioBrowser(url: string, projectName: string, options?: BrowserLaunchOptions): void {
|
||||
if (options?.noOpen) return;
|
||||
openBrowser(`${url}#project/${projectName}`, {
|
||||
browserPath: options?.browserPath,
|
||||
userDataDir: options?.userDataDir,
|
||||
remoteDebuggingPort: options?.remoteDebuggingPort,
|
||||
});
|
||||
}
|
||||
|
||||
function printStudioSummary(
|
||||
projectName: string,
|
||||
url: string,
|
||||
opts: { details?: string[]; footer?: string } = {},
|
||||
): void {
|
||||
console.log();
|
||||
console.log(` ${c.dim("Project")} ${c.accent(projectName)}`);
|
||||
console.log(` ${c.dim("Studio")} ${c.accent(url)}`);
|
||||
console.log();
|
||||
for (const detail of opts.details ?? []) {
|
||||
console.log(` ${c.dim(detail)}`);
|
||||
}
|
||||
if (opts.details?.length && opts.footer) console.log();
|
||||
if (opts.footer) console.log(` ${c.dim(opts.footer)}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
function linkProjectIntoStudioData(
|
||||
dir: string,
|
||||
projectsDir: string,
|
||||
projectName: string,
|
||||
): { symlinkPath: string; createdSymlink: boolean } {
|
||||
const symlinkPath = join(projectsDir, projectName);
|
||||
mkdirSync(projectsDir, { recursive: true });
|
||||
|
||||
let createdSymlink = false;
|
||||
@@ -227,24 +674,88 @@ async function runDevMode(
|
||||
if (existsSync(symlinkPath)) {
|
||||
try {
|
||||
const stat = lstatSync(symlinkPath);
|
||||
if (stat.isSymbolicLink()) {
|
||||
const target = readlinkSync(symlinkPath);
|
||||
if (resolve(target) !== resolve(dir)) {
|
||||
unlinkSync(symlinkPath);
|
||||
}
|
||||
if (stat.isSymbolicLink() && resolve(readlinkSync(symlinkPath)) !== resolve(dir)) {
|
||||
unlinkSync(symlinkPath);
|
||||
}
|
||||
// If it's a real directory, leave it alone
|
||||
} catch {
|
||||
// Not a symlink — don't touch it
|
||||
// Real directories or unreadable paths are left untouched.
|
||||
}
|
||||
}
|
||||
|
||||
if (!existsSync(symlinkPath)) {
|
||||
symlinkSync(dir, symlinkPath, "dir");
|
||||
createdSymlink = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { symlinkPath, createdSymlink };
|
||||
}
|
||||
|
||||
function removeSymlinkOnExit(createdSymlink: boolean, symlinkPath: string): void {
|
||||
if (!createdSymlink) return;
|
||||
process.on("exit", () => {
|
||||
try {
|
||||
if (existsSync(symlinkPath)) unlinkSync(symlinkPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function registerChildTreeShutdown(child: StudioChildProcess): void {
|
||||
const shutdown = (): void => {
|
||||
if (child.pid) killProcessTree(child.pid);
|
||||
};
|
||||
process.once("SIGINT", shutdown);
|
||||
process.once("SIGTERM", shutdown);
|
||||
}
|
||||
|
||||
function waitForChildClose(child: StudioChildProcess): Promise<void> {
|
||||
return new Promise<void>((resolveClose) => {
|
||||
child.on("close", () => resolveClose());
|
||||
});
|
||||
}
|
||||
|
||||
function attachStudioReadyHandler(
|
||||
child: StudioChildProcess,
|
||||
spinner: ReturnType<typeof clack.spinner>,
|
||||
projectName: string,
|
||||
options?: BrowserLaunchOptions,
|
||||
): void {
|
||||
let detected = false;
|
||||
|
||||
function handleOutput(data: Buffer): void {
|
||||
const url = data.toString().match(/Local:\s+(http:\/\/localhost:\d+)/)?.[1];
|
||||
if (!url || detected) return;
|
||||
|
||||
detected = true;
|
||||
spinner.stop(c.success("Studio running"));
|
||||
printStudioSummary(projectName, url, { footer: "Press Ctrl+C to stop" });
|
||||
openStudioBrowser(url, projectName, options);
|
||||
child.stdout.removeListener("data", handleOutput);
|
||||
child.stderr.removeListener("data", handleOutput);
|
||||
}
|
||||
|
||||
child.stdout.on("data", handleOutput);
|
||||
child.stderr.on("data", handleOutput);
|
||||
child.on("error", (err) => {
|
||||
spinner.stop(c.error("Failed to start studio"));
|
||||
console.error(c.dim(err.message));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dev mode: spawn the studio dev server from the monorepo.
|
||||
*/
|
||||
async function runDevMode(dir: string, options?: StudioLaunchOptions): Promise<void> {
|
||||
// Find monorepo root by navigating from packages/cli/src/commands/
|
||||
const thisFile = fileURLToPath(import.meta.url);
|
||||
const repoRoot = resolve(dirname(thisFile), "..", "..", "..", "..");
|
||||
|
||||
// Symlink project into the studio's data directory
|
||||
const projectsDir = join(repoRoot, "packages", "studio", "data", "projects");
|
||||
const pName = options?.projectName ?? basename(dir);
|
||||
const { symlinkPath, createdSymlink } = linkProjectIntoStudioData(dir, projectsDir, pName);
|
||||
|
||||
clack.intro(c.bold("hyperframes preview"));
|
||||
|
||||
const s = clack.spinner();
|
||||
@@ -257,55 +768,8 @@ async function runDevMode(
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let frontendUrl = "";
|
||||
|
||||
function handleOutput(data: Buffer): void {
|
||||
const text = data.toString();
|
||||
|
||||
// Detect Vite URL
|
||||
const localMatch = text.match(/Local:\s+(http:\/\/localhost:\d+)/);
|
||||
if (localMatch && !frontendUrl) {
|
||||
frontendUrl = localMatch[1] ?? "";
|
||||
s.stop(c.success("Studio running"));
|
||||
console.log();
|
||||
console.log(` ${c.dim("Project")} ${c.accent(pName)}`);
|
||||
console.log(` ${c.dim("Studio")} ${c.accent(frontendUrl)}`);
|
||||
console.log();
|
||||
console.log(` ${c.dim("Press Ctrl+C to stop")}`);
|
||||
console.log();
|
||||
|
||||
if (!options?.noOpen) {
|
||||
const urlToOpen = `${frontendUrl}#project/${pName}`;
|
||||
openBrowser(urlToOpen, {
|
||||
browserPath: options?.browserPath,
|
||||
userDataDir: options?.userDataDir,
|
||||
remoteDebuggingPort: options?.remoteDebuggingPort,
|
||||
});
|
||||
}
|
||||
|
||||
child.stdout?.removeListener("data", handleOutput);
|
||||
child.stderr?.removeListener("data", handleOutput);
|
||||
}
|
||||
}
|
||||
|
||||
child.stdout?.on("data", handleOutput);
|
||||
child.stderr?.on("data", handleOutput);
|
||||
|
||||
// If child exits before we detect readiness, show what we have
|
||||
child.on("error", (err) => {
|
||||
s.stop(c.error("Failed to start studio"));
|
||||
console.error(c.dim(err.message));
|
||||
});
|
||||
|
||||
if (createdSymlink) {
|
||||
process.on("exit", () => {
|
||||
try {
|
||||
if (existsSync(symlinkPath)) unlinkSync(symlinkPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
}
|
||||
attachStudioReadyHandler(child, s, pName, options);
|
||||
removeSymlinkOnExit(createdSymlink, symlinkPath);
|
||||
|
||||
// Kill the child's entire process tree on SIGTERM/SIGINT. Ctrl+C sends
|
||||
// SIGINT to the foreground process group (covers the common case), but
|
||||
@@ -313,15 +777,8 @@ async function runDevMode(
|
||||
// would survive without explicit cleanup.
|
||||
// On Windows, killProcessTree is a no-op (pgrep/ps unavailable); Ctrl+C
|
||||
// propagates via the console process group instead.
|
||||
const shutdown = () => {
|
||||
if (child.pid) killProcessTree(child.pid);
|
||||
};
|
||||
process.once("SIGINT", shutdown);
|
||||
process.once("SIGTERM", shutdown);
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
child.on("close", () => resolve());
|
||||
});
|
||||
registerChildTreeShutdown(child);
|
||||
return waitForChildClose(child);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -341,37 +798,14 @@ function hasLocalStudio(dir: string): boolean {
|
||||
* Local studio mode: spawn Vite using a locally installed @hyperframes/studio.
|
||||
* Provides full Vite HMR and the complete studio experience.
|
||||
*/
|
||||
async function runLocalStudioMode(
|
||||
dir: string,
|
||||
options?: {
|
||||
projectName?: string;
|
||||
noOpen?: boolean;
|
||||
browserPath?: string;
|
||||
userDataDir?: string;
|
||||
remoteDebuggingPort?: number;
|
||||
},
|
||||
): Promise<void> {
|
||||
async function runLocalStudioMode(dir: string, options?: StudioLaunchOptions): Promise<void> {
|
||||
const req = createRequire(join(dir, "package.json"));
|
||||
const studioPkgPath = dirname(req.resolve("@hyperframes/studio/package.json"));
|
||||
const pName = options?.projectName ?? basename(dir);
|
||||
|
||||
// Symlink project into studio's data directory
|
||||
const projectsDir = join(studioPkgPath, "data", "projects");
|
||||
const symlinkPath = join(projectsDir, pName);
|
||||
mkdirSync(projectsDir, { recursive: true });
|
||||
|
||||
let createdSymlink = false;
|
||||
if (dir !== symlinkPath) {
|
||||
if (existsSync(symlinkPath) && lstatSync(symlinkPath).isSymbolicLink()) {
|
||||
if (resolve(readlinkSync(symlinkPath)) !== resolve(dir)) {
|
||||
unlinkSync(symlinkPath);
|
||||
}
|
||||
}
|
||||
if (!existsSync(symlinkPath)) {
|
||||
symlinkSync(dir, symlinkPath, "dir");
|
||||
createdSymlink = true;
|
||||
}
|
||||
}
|
||||
const { symlinkPath, createdSymlink } = linkProjectIntoStudioData(dir, projectsDir, pName);
|
||||
|
||||
clack.intro(c.bold("hyperframes preview") + c.dim(" (local studio)"));
|
||||
const s = clack.spinner();
|
||||
@@ -383,58 +817,12 @@ async function runLocalStudioMode(
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let detected = false;
|
||||
|
||||
function handleOutput(data: Buffer): void {
|
||||
const text = data.toString();
|
||||
const localMatch = text.match(/Local:\s+(http:\/\/localhost:\d+)/);
|
||||
if (localMatch && !detected) {
|
||||
detected = true;
|
||||
const url = localMatch[1] ?? "";
|
||||
s.stop(c.success("Studio running"));
|
||||
console.log();
|
||||
console.log(` ${c.dim("Project")} ${c.accent(pName)}`);
|
||||
console.log(` ${c.dim("Studio")} ${c.accent(url)}`);
|
||||
console.log();
|
||||
console.log(` ${c.dim("Press Ctrl+C to stop")}`);
|
||||
console.log();
|
||||
if (!options?.noOpen) {
|
||||
openBrowser(`${url}#project/${pName}`, {
|
||||
browserPath: options?.browserPath,
|
||||
userDataDir: options?.userDataDir,
|
||||
remoteDebuggingPort: options?.remoteDebuggingPort,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
child.stdout?.on("data", handleOutput);
|
||||
child.stderr?.on("data", handleOutput);
|
||||
child.on("error", (err) => {
|
||||
s.stop(c.error("Failed to start studio"));
|
||||
console.error(c.dim(err.message));
|
||||
});
|
||||
|
||||
if (createdSymlink) {
|
||||
process.on("exit", () => {
|
||||
try {
|
||||
if (existsSync(symlinkPath)) unlinkSync(symlinkPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
}
|
||||
attachStudioReadyHandler(child, s, pName, options);
|
||||
removeSymlinkOnExit(createdSymlink, symlinkPath);
|
||||
|
||||
// Same tree-kill handler as dev mode. No-op on Windows (see comment above).
|
||||
const shutdown = () => {
|
||||
if (child.pid) killProcessTree(child.pid);
|
||||
};
|
||||
process.once("SIGINT", shutdown);
|
||||
process.once("SIGTERM", shutdown);
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
child.on("close", () => resolve());
|
||||
});
|
||||
registerChildTreeShutdown(child);
|
||||
return waitForChildClose(child);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -447,14 +835,7 @@ async function runLocalStudioMode(
|
||||
async function runEmbeddedMode(
|
||||
dir: string,
|
||||
startPort: number,
|
||||
options?: {
|
||||
projectName?: string;
|
||||
forceNew?: boolean;
|
||||
noOpen?: boolean;
|
||||
browserPath?: string;
|
||||
userDataDir?: string;
|
||||
remoteDebuggingPort?: number;
|
||||
},
|
||||
options?: EmbeddedStudioOptions,
|
||||
): Promise<void> {
|
||||
const { createStudioServer, loadPreviewServerBuildSignature, resolveStudioBundle } =
|
||||
await import("../server/studioServer.js");
|
||||
@@ -504,21 +885,10 @@ async function runEmbeddedMode(
|
||||
if (result.type === "already-running") {
|
||||
const url = `http://localhost:${result.port}`;
|
||||
s.stop(c.success("Already running"));
|
||||
console.log();
|
||||
console.log(` ${c.dim("Project")} ${c.accent(pName)}`);
|
||||
console.log(` ${c.dim("Studio")} ${c.accent(url)}`);
|
||||
console.log();
|
||||
console.log(
|
||||
` ${c.dim("Reusing existing server. Use --force-new to start a fresh instance.")}`,
|
||||
);
|
||||
console.log();
|
||||
if (!options?.noOpen) {
|
||||
openBrowser(`${url}#project/${pName}`, {
|
||||
browserPath: options?.browserPath,
|
||||
userDataDir: options?.userDataDir,
|
||||
remoteDebuggingPort: options?.remoteDebuggingPort,
|
||||
});
|
||||
}
|
||||
printStudioSummary(pName, url, {
|
||||
details: ["Reusing existing server. Use --force-new to start a fresh instance."],
|
||||
});
|
||||
openStudioBrowser(url, pName, options);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -529,21 +899,14 @@ async function runEmbeddedMode(
|
||||
console.log(` ${c.warn(`Port ${startPort} is in use, using ${result.port} instead`)}`);
|
||||
console.log();
|
||||
}
|
||||
console.log(` ${c.dim("Project")} ${c.accent(pName)}`);
|
||||
console.log(` ${c.dim("Studio")} ${c.accent(url)}`);
|
||||
console.log();
|
||||
console.log(` ${c.dim("Edit with your AI agent — it has HyperFrames skills installed.")}`);
|
||||
console.log(` ${c.dim("Changes reload automatically in the studio.")}`);
|
||||
console.log();
|
||||
console.log(` ${c.dim("Press Ctrl+C to stop")}`);
|
||||
console.log();
|
||||
if (!options?.noOpen) {
|
||||
openBrowser(`${url}#project/${pName}`, {
|
||||
browserPath: options?.browserPath,
|
||||
userDataDir: options?.userDataDir,
|
||||
remoteDebuggingPort: options?.remoteDebuggingPort,
|
||||
});
|
||||
}
|
||||
printStudioSummary(pName, url, {
|
||||
details: [
|
||||
"Edit with your AI agent — it has HyperFrames skills installed.",
|
||||
"Changes reload automatically in the studio.",
|
||||
],
|
||||
footer: "Press Ctrl+C to stop",
|
||||
});
|
||||
openStudioBrowser(url, pName, options);
|
||||
|
||||
// Block until Ctrl+C. Node would normally exit on SIGINT, but the listening
|
||||
// HTTP server keeps handles open, so the event loop stays alive after the
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user