mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { registerSelectionRoutes } from "./selection";
|
||||
import type { StudioApiAdapter, StudioSelectionSnapshot } from "../types";
|
||||
|
||||
function createAdapter(): StudioApiAdapter {
|
||||
return {
|
||||
listProjects: () => [],
|
||||
resolveProject: async (id: string) =>
|
||||
id === "demo" ? { id, dir: "/tmp/demo", title: "Demo" } : null,
|
||||
bundle: async () => null,
|
||||
lint: async () => ({ findings: [] }),
|
||||
runtimeUrl: "/api/runtime.js",
|
||||
rendersDir: () => "/tmp/renders",
|
||||
startRender: () => ({
|
||||
id: "job-1",
|
||||
status: "rendering",
|
||||
progress: 0,
|
||||
outputPath: "/tmp/out.mp4",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const selection = {
|
||||
schemaVersion: 1,
|
||||
projectId: "demo",
|
||||
compositionPath: "index.html",
|
||||
sourceFile: "index.html",
|
||||
currentTime: 1.25,
|
||||
target: { hfId: "hero-title", selector: ".title", selectorIndex: 0 },
|
||||
label: "Hero title",
|
||||
tagName: "h1",
|
||||
boundingBox: { x: 10, y: 20, width: 300, height: 64 },
|
||||
textContent: "Launch faster",
|
||||
dataAttributes: { "data-hf-id": "hero-title" },
|
||||
inlineStyles: { color: "white" },
|
||||
computedStyles: { "font-size": "48px" },
|
||||
textFields: [
|
||||
{
|
||||
key: "self",
|
||||
label: "Text",
|
||||
value: "Launch faster",
|
||||
tagName: "h1",
|
||||
source: "self",
|
||||
},
|
||||
],
|
||||
capabilities: { canSelect: true, canEditStyles: true },
|
||||
thumbnailUrl:
|
||||
"/api/projects/demo/thumbnail/index.html?t=1.25&format=png&selector=.title&selectorIndex=0",
|
||||
} satisfies StudioSelectionSnapshot;
|
||||
|
||||
describe("registerSelectionRoutes", () => {
|
||||
it("stores and returns the latest Studio selection snapshot", async () => {
|
||||
const app = new Hono();
|
||||
registerSelectionRoutes(app, createAdapter());
|
||||
|
||||
const put = await app.request("http://localhost/projects/demo/selection", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ selection }),
|
||||
});
|
||||
expect(put.status).toBe(200);
|
||||
|
||||
const response = await app.request("http://localhost/projects/demo/selection");
|
||||
const payload = (await response.json()) as {
|
||||
selection?: StudioSelectionSnapshot | null;
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.selection).toMatchObject({
|
||||
projectId: "demo",
|
||||
sourceFile: "index.html",
|
||||
target: { hfId: "hero-title" },
|
||||
thumbnailUrl: expect.stringContaining("/thumbnail/index.html"),
|
||||
});
|
||||
expect(payload.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
it("clears a stored selection when Studio posts null", async () => {
|
||||
const app = new Hono();
|
||||
registerSelectionRoutes(app, createAdapter());
|
||||
|
||||
await app.request("http://localhost/projects/demo/selection", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ selection }),
|
||||
});
|
||||
|
||||
const clear = await app.request("http://localhost/projects/demo/selection", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ selection: null }),
|
||||
});
|
||||
expect(clear.status).toBe(200);
|
||||
|
||||
const response = await app.request("http://localhost/projects/demo/selection");
|
||||
const payload = (await response.json()) as {
|
||||
selection?: StudioSelectionSnapshot | null;
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
expect(payload.selection).toBeNull();
|
||||
expect(payload.updatedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects malformed selection payloads", async () => {
|
||||
const app = new Hono();
|
||||
registerSelectionRoutes(app, createAdapter());
|
||||
|
||||
const response = await app.request("http://localhost/projects/demo/selection", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ selection: { projectId: "demo" } }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { Hono } from "hono";
|
||||
import type {
|
||||
StudioApiAdapter,
|
||||
StudioSelectionResponse,
|
||||
StudioSelectionSnapshot,
|
||||
} from "../types.js";
|
||||
|
||||
interface StoredSelection {
|
||||
selection: StudioSelectionSnapshot;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function isStringRecord(value: unknown): value is Record<string, string> {
|
||||
return isRecord(value) && Object.values(value).every((v) => typeof v === "string");
|
||||
}
|
||||
|
||||
function hasString(value: Record<string, unknown>, key: string): boolean {
|
||||
return typeof value[key] === "string";
|
||||
}
|
||||
|
||||
function hasRequiredStrings(value: Record<string, unknown>, keys: string[]): boolean {
|
||||
return keys.every((key) => hasString(value, key));
|
||||
}
|
||||
|
||||
function hasOptionalString(value: Record<string, unknown>, key: string): boolean {
|
||||
return value[key] === undefined || typeof value[key] === "string";
|
||||
}
|
||||
|
||||
function hasOptionalNullableString(value: Record<string, unknown>, key: string): boolean {
|
||||
return value[key] == null || typeof value[key] === "string";
|
||||
}
|
||||
|
||||
function hasOptionalNumber(value: Record<string, unknown>, key: string): boolean {
|
||||
return value[key] === undefined || isFiniteNumber(value[key]);
|
||||
}
|
||||
|
||||
function isBoundingBox(value: unknown): value is StudioSelectionSnapshot["boundingBox"] {
|
||||
return (
|
||||
isRecord(value) && ["x", "y", "width", "height"].every((key) => isFiniteNumber(value[key]))
|
||||
);
|
||||
}
|
||||
|
||||
function isTarget(value: unknown): value is StudioSelectionSnapshot["target"] {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
hasOptionalNullableString(value, "id") &&
|
||||
hasOptionalString(value, "hfId") &&
|
||||
hasOptionalString(value, "selector") &&
|
||||
hasOptionalNumber(value, "selectorIndex")
|
||||
);
|
||||
}
|
||||
|
||||
function isTextField(value: unknown): value is StudioSelectionSnapshot["textFields"][number] {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
hasRequiredStrings(value, ["key", "label", "value", "tagName"]) &&
|
||||
["self", "child", "text-node"].includes(value.source as string)
|
||||
);
|
||||
}
|
||||
|
||||
function isTextFields(value: unknown): value is StudioSelectionSnapshot["textFields"] {
|
||||
return Array.isArray(value) && value.every(isTextField);
|
||||
}
|
||||
|
||||
function isSelectionSnapshot(value: unknown): value is StudioSelectionSnapshot {
|
||||
if (!isRecord(value)) return false;
|
||||
|
||||
const checks = [
|
||||
value.schemaVersion === 1 &&
|
||||
hasRequiredStrings(value, [
|
||||
"projectId",
|
||||
"compositionPath",
|
||||
"sourceFile",
|
||||
"label",
|
||||
"tagName",
|
||||
"thumbnailUrl",
|
||||
]),
|
||||
isFiniteNumber(value.currentTime),
|
||||
isTarget(value.target),
|
||||
isBoundingBox(value.boundingBox),
|
||||
value.textContent === null || typeof value.textContent === "string",
|
||||
isStringRecord(value.dataAttributes),
|
||||
isStringRecord(value.inlineStyles),
|
||||
isStringRecord(value.computedStyles),
|
||||
isTextFields(value.textFields),
|
||||
isRecord(value.capabilities),
|
||||
];
|
||||
|
||||
return checks.every(Boolean);
|
||||
}
|
||||
|
||||
export function registerSelectionRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
const selections = new Map<string, StoredSelection>();
|
||||
|
||||
api.get("/projects/:id/selection", async (c) => {
|
||||
const project = await adapter.resolveProject(c.req.param("id"));
|
||||
if (!project) return c.json({ error: "not found" }, 404);
|
||||
const stored = selections.get(project.id);
|
||||
return c.json({
|
||||
selection: stored?.selection ?? null,
|
||||
updatedAt: stored?.updatedAt ?? null,
|
||||
} satisfies StudioSelectionResponse);
|
||||
});
|
||||
|
||||
api.put("/projects/:id/selection", async (c) => {
|
||||
const project = await adapter.resolveProject(c.req.param("id"));
|
||||
if (!project) return c.json({ error: "not found" }, 404);
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ error: "invalid json" }, 400);
|
||||
}
|
||||
if (!isRecord(body) || !("selection" in body)) {
|
||||
return c.json({ error: "missing selection" }, 400);
|
||||
}
|
||||
|
||||
if (body.selection === null) {
|
||||
selections.delete(project.id);
|
||||
return c.json({ ok: true, selection: null, updatedAt: null });
|
||||
}
|
||||
|
||||
if (!isSelectionSnapshot(body.selection)) {
|
||||
return c.json({ error: "invalid selection" }, 400);
|
||||
}
|
||||
|
||||
const selection = { ...body.selection, projectId: project.id };
|
||||
const updatedAt = new Date().toISOString();
|
||||
selections.set(project.id, { selection, updatedAt });
|
||||
return c.json({ ok: true, selection, updatedAt });
|
||||
});
|
||||
}
|
||||
@@ -29,6 +29,43 @@ export interface LintResult {
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface StudioSelectionTextField {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
tagName: string;
|
||||
source: "self" | "child" | "text-node";
|
||||
}
|
||||
|
||||
export interface StudioSelectionSnapshot {
|
||||
schemaVersion: 1;
|
||||
projectId: string;
|
||||
compositionPath: string;
|
||||
sourceFile: string;
|
||||
currentTime: number;
|
||||
target: {
|
||||
id?: string | null;
|
||||
hfId?: string;
|
||||
selector?: string;
|
||||
selectorIndex?: number;
|
||||
};
|
||||
label: string;
|
||||
tagName: string;
|
||||
boundingBox: { x: number; y: number; width: number; height: number };
|
||||
textContent: string | null;
|
||||
dataAttributes: Record<string, string>;
|
||||
inlineStyles: Record<string, string>;
|
||||
computedStyles: Record<string, string>;
|
||||
textFields: StudioSelectionTextField[];
|
||||
capabilities: Record<string, boolean | string | undefined>;
|
||||
thumbnailUrl: string;
|
||||
}
|
||||
|
||||
export interface StudioSelectionResponse {
|
||||
selection: StudioSelectionSnapshot | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter interface — injected by each consumer to handle host-specific behavior.
|
||||
* The shared API module calls these methods; each host (vite dev, CLI embedded)
|
||||
|
||||
@@ -83,7 +83,7 @@ export function StudioApp() {
|
||||
const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
|
||||
const [compositionLoading, setCompositionLoading] = useState(true);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [, setPreviewDocumentVersion] = useState(0);
|
||||
const [previewDocumentVersion, setPreviewDocumentVersion] = useState(0);
|
||||
const [blockPreview, setBlockPreview] = useState<BlockPreviewInfo | null>(null);
|
||||
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const activeCompPathRef = useRef(activeCompPath);
|
||||
@@ -296,6 +296,7 @@ export function StudioApp() {
|
||||
projectIdRef: fileManager.projectIdRef,
|
||||
previewIframe,
|
||||
refreshKey,
|
||||
previewDocumentVersion,
|
||||
rightPanelTab: panelLayout.rightPanelTab,
|
||||
applyStudioManualEditsToPreviewRef: previewPersistence.applyStudioManualEditsToPreviewRef,
|
||||
syncPreviewHistoryHotkey: appHotkeys.syncPreviewHistoryHotkey,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useGsapScriptCommits } from "./useGsapScriptCommits";
|
||||
import { useGsapCacheVersion } from "./useGsapTweenCache";
|
||||
import { useDomEditWiring } from "./useDomEditWiring";
|
||||
import { useGsapAwareEditing } from "./useGsapAwareEditing";
|
||||
import { useStudioSelectionPublisher } from "./useStudioSelectionPublisher";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
@@ -54,6 +55,7 @@ export interface UseDomEditSessionParams {
|
||||
projectIdRef: React.MutableRefObject<string | null>;
|
||||
previewIframe: HTMLIFrameElement | null;
|
||||
refreshKey: number;
|
||||
previewDocumentVersion: number;
|
||||
rightPanelTab: RightPanelTab;
|
||||
applyStudioManualEditsToPreviewRef: React.MutableRefObject<
|
||||
(iframe: HTMLIFrameElement) => Promise<void>
|
||||
@@ -96,6 +98,7 @@ export function useDomEditSession({
|
||||
projectIdRef,
|
||||
previewIframe,
|
||||
refreshKey,
|
||||
previewDocumentVersion,
|
||||
rightPanelTab,
|
||||
applyStudioManualEditsToPreviewRef,
|
||||
syncPreviewHistoryHotkey,
|
||||
@@ -168,6 +171,15 @@ export function useDomEditSession({
|
||||
domEditSelection,
|
||||
});
|
||||
|
||||
useStudioSelectionPublisher({
|
||||
projectId,
|
||||
domEditSelection,
|
||||
domEditSelectionRef,
|
||||
refreshKey,
|
||||
previewDocumentVersion,
|
||||
refreshDomEditSelectionFromPreview,
|
||||
});
|
||||
|
||||
// ── GSAP cache (hoisted so both useGsapScriptCommits and useDomEditWiring share the same instance) ──
|
||||
|
||||
const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion();
|
||||
|
||||
@@ -381,7 +381,10 @@ export function useDomSelection({
|
||||
if (!doc) return;
|
||||
|
||||
const element = findElementForSelection(doc, selection, activeCompPath);
|
||||
if (!element) return;
|
||||
if (!element) {
|
||||
applyDomSelection(null, { revealPanel: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSelection = await buildDomSelectionFromTarget(element);
|
||||
if (nextSelection) {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useRef, type MutableRefObject } from "react";
|
||||
import type { DomEditSelection } from "../components/editor/domEditing";
|
||||
import { usePlayerStore } from "../player";
|
||||
import { buildStudioSelectionSnapshot } from "../utils/studioSelectionSnapshot";
|
||||
import { trackStudioEvent } from "../utils/studioTelemetry";
|
||||
|
||||
interface UseStudioSelectionPublisherParams {
|
||||
projectId: string | null;
|
||||
domEditSelection: DomEditSelection | null;
|
||||
domEditSelectionRef: MutableRefObject<DomEditSelection | null>;
|
||||
refreshKey: number;
|
||||
previewDocumentVersion: number;
|
||||
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise<void>;
|
||||
}
|
||||
|
||||
function reportSelectionPublishError(error: unknown): void {
|
||||
if (error instanceof Error && error.name === "AbortError") return;
|
||||
const errorName = error instanceof Error ? error.name : typeof error;
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
trackStudioEvent("studio_selection_publish_failed", {
|
||||
error_name: errorName,
|
||||
error_message: errorMessage.slice(0, 500),
|
||||
});
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("[Studio] Failed to update agent selection context", error);
|
||||
}
|
||||
|
||||
function putSelection(projectId: string, selection: unknown, signal?: AbortSignal): Promise<void> {
|
||||
return fetch(`/api/projects/${encodeURIComponent(projectId)}/selection`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ selection }),
|
||||
signal,
|
||||
}).then(() => undefined);
|
||||
}
|
||||
|
||||
export function useStudioSelectionPublisher({
|
||||
projectId,
|
||||
domEditSelection,
|
||||
domEditSelectionRef,
|
||||
refreshKey,
|
||||
previewDocumentVersion,
|
||||
refreshDomEditSelectionFromPreview,
|
||||
}: UseStudioSelectionPublisherParams): void {
|
||||
const lastSelectionRefreshKeyRef = useRef(refreshKey);
|
||||
const pendingSelectionRefreshKeyRef = useRef<number | null>(null);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
const selection = domEditSelection?.element.isConnected
|
||||
? buildStudioSelectionSnapshot({
|
||||
projectId,
|
||||
selection: domEditSelection,
|
||||
currentTime: usePlayerStore.getState().currentTime,
|
||||
})
|
||||
: null;
|
||||
const controller = new AbortController();
|
||||
void putSelection(projectId, selection, controller.signal).catch(reportSelectionPublishError);
|
||||
return () => controller.abort();
|
||||
}, [domEditSelection, projectId]);
|
||||
|
||||
// Clear server-side agent context when Studio leaves a project. Without this,
|
||||
// a long-running multi-project preview server can keep serving the last
|
||||
// selected element for a project after its tab/session unmounts.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
return () => {
|
||||
void putSelection(projectId, null).catch(reportSelectionPublishError);
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
// On external file edits, the iframe reloads while React keeps the previous
|
||||
// DOM selection object alive. Clear the agent-facing snapshot immediately so
|
||||
// `preview --context` never serves a detached or stale target, then let the
|
||||
// post-load preview document refresh below re-resolve the selection if it
|
||||
// still exists in the new document.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (lastSelectionRefreshKeyRef.current === refreshKey) return;
|
||||
lastSelectionRefreshKeyRef.current = refreshKey;
|
||||
pendingSelectionRefreshKeyRef.current = domEditSelectionRef.current ? refreshKey : null;
|
||||
if (!projectId || !domEditSelectionRef.current) return;
|
||||
const controller = new AbortController();
|
||||
void putSelection(projectId, null, controller.signal).catch(reportSelectionPublishError);
|
||||
return () => controller.abort();
|
||||
}, [domEditSelectionRef, projectId, refreshKey]);
|
||||
|
||||
// `refreshPreviewDocumentVersion` ticks after iframe load and shortly after.
|
||||
// Consume one pending refresh per external reload: enough to re-resolve the
|
||||
// selected element once the new document is queryable, without republishing
|
||||
// the same snapshot on every follow-up 80/300ms tick.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (pendingSelectionRefreshKeyRef.current === null) return;
|
||||
pendingSelectionRefreshKeyRef.current = null;
|
||||
const selection = domEditSelectionRef.current;
|
||||
if (!selection) return;
|
||||
void refreshDomEditSelectionFromPreview(selection);
|
||||
}, [domEditSelectionRef, previewDocumentVersion, refreshDomEditSelectionFromPreview]);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildStudioSelectionSnapshot } from "./studioSelectionSnapshot";
|
||||
import type { DomEditSelection } from "../components/editor/domEditing";
|
||||
|
||||
describe("buildStudioSelectionSnapshot", () => {
|
||||
it("serializes a DOM edit selection without the live HTMLElement", () => {
|
||||
const selection = {
|
||||
element: { tagName: "H1" } as HTMLElement,
|
||||
id: null,
|
||||
hfId: "hero-title",
|
||||
selector: ".title",
|
||||
selectorIndex: 0,
|
||||
label: "Hero title",
|
||||
tagName: "h1",
|
||||
sourceFile: "index.html",
|
||||
compositionPath: "index.html",
|
||||
isCompositionHost: false,
|
||||
isInsideLockedComposition: false,
|
||||
boundingBox: { x: 10, y: 20, width: 300, height: 64 },
|
||||
textContent: "Launch faster",
|
||||
dataAttributes: { "data-hf-id": "hero-title" },
|
||||
inlineStyles: { color: "white" },
|
||||
computedStyles: { "font-size": "48px" },
|
||||
textFields: [],
|
||||
capabilities: { canSelect: true, canEditStyles: true },
|
||||
} as DomEditSelection;
|
||||
|
||||
const snapshot = buildStudioSelectionSnapshot({
|
||||
projectId: "demo",
|
||||
selection,
|
||||
currentTime: 1.25,
|
||||
});
|
||||
|
||||
expect(snapshot).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
projectId: "demo",
|
||||
compositionPath: "index.html",
|
||||
sourceFile: "index.html",
|
||||
currentTime: 1.25,
|
||||
target: { hfId: "hero-title", selector: ".title", selectorIndex: 0 },
|
||||
thumbnailUrl:
|
||||
"/api/projects/demo/thumbnail/index.html?t=1.25&format=png&selector=.title&selectorIndex=0",
|
||||
});
|
||||
expect(JSON.stringify(snapshot)).not.toContain("HTMLElement");
|
||||
expect(snapshot).not.toHaveProperty("element");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { StudioSelectionSnapshot } from "@hyperframes/studio-server";
|
||||
import type { DomEditSelection } from "../components/editor/domEditing";
|
||||
|
||||
function round3(value: number): number {
|
||||
return Math.round(value * 1000) / 1000;
|
||||
}
|
||||
|
||||
function thumbnailUrl({
|
||||
projectId,
|
||||
selection,
|
||||
currentTime,
|
||||
}: {
|
||||
projectId: string;
|
||||
selection: DomEditSelection;
|
||||
currentTime: number;
|
||||
}): string {
|
||||
const compPath = encodeURIComponent(
|
||||
selection.compositionPath || selection.sourceFile || "index.html",
|
||||
);
|
||||
const params = new URLSearchParams({
|
||||
t: String(round3(currentTime)),
|
||||
format: "png",
|
||||
});
|
||||
if (selection.selector) params.set("selector", selection.selector);
|
||||
if (selection.selectorIndex != null) params.set("selectorIndex", String(selection.selectorIndex));
|
||||
return `/api/projects/${encodeURIComponent(projectId)}/thumbnail/${compPath}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function buildStudioSelectionSnapshot({
|
||||
projectId,
|
||||
selection,
|
||||
currentTime,
|
||||
}: {
|
||||
projectId: string;
|
||||
selection: DomEditSelection;
|
||||
currentTime: number;
|
||||
}): StudioSelectionSnapshot {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
projectId,
|
||||
compositionPath: selection.compositionPath,
|
||||
sourceFile: selection.sourceFile,
|
||||
currentTime: round3(currentTime),
|
||||
target: {
|
||||
id: selection.id,
|
||||
hfId: selection.hfId,
|
||||
selector: selection.selector,
|
||||
selectorIndex: selection.selectorIndex,
|
||||
},
|
||||
label: selection.label,
|
||||
tagName: selection.tagName,
|
||||
boundingBox: {
|
||||
x: round3(selection.boundingBox.x),
|
||||
y: round3(selection.boundingBox.y),
|
||||
width: round3(selection.boundingBox.width),
|
||||
height: round3(selection.boundingBox.height),
|
||||
},
|
||||
textContent: selection.textContent,
|
||||
dataAttributes: { ...selection.dataAttributes },
|
||||
inlineStyles: { ...selection.inlineStyles },
|
||||
computedStyles: { ...selection.computedStyles },
|
||||
textFields: selection.textFields.map((field) => ({
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
value: field.value,
|
||||
tagName: field.tagName,
|
||||
source: field.source,
|
||||
})),
|
||||
capabilities: { ...selection.capabilities },
|
||||
thumbnailUrl: thumbnailUrl({ projectId, selection, currentTime }),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user