mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
P1 — the required Test lane was red, and it was my test. The
hostile-Host SPA case asserted a 200, which only holds when
packages/studio/dist is built: true on a dev box, false in CI, so it
passed locally and failed there. The Host split moved into a pure
buildStudioHeadScriptsForHost() and is asserted directly; the route test
no longer depends on build state. Verified by running the CLI suite with
the bundle moved aside — 2330 pass.
P1 — studio:* still bypassed most privacy controls. It honoured two
localStorage keys but not navigator.doNotTrack,
VITE_HYPERFRAMES_NO_TELEMETRY, Vite dev mode or API-key eligibility, and
canary enrolment honoured a different single control. New
telemetry/policy.ts is the one answer to "may this profile be measured",
consumed by both transports and by enrolment. It imports only ./config,
so no cycle with the modules that import it. Each control is asserted
individually.
P1 — LAN/remote preview lost the authoritative decisions. Withholding
the whole head script for any non-loopback Host also dropped the safe
{enabled, forced} map, sending a supported HYPERFRAMES_PREVIEW_HOST=
0.0.0.0 Studio back to re-deriving. Identity injection is now gated
separately from decision injection: identity is loopback-only, decisions
always publish.
P1 — the breaker latch was neither authoritative nor truthfully
persisted. syncInstallState swallowed its own failures so
writeConfigWithResult always reported ok, and reads took the flag only
from config.json. The latch is now merged into every effective read,
which makes install-state authoritative and closes both the failed-mirror
and stale-concurrent-writer paths; the write additionally reports
mirrored: false rather than swallowing.
P2 — public contracts. canary-rollouts.mdx said a config wipe loses the
breaker (it does not) and documented the superseded {name: boolean} map
with unconditional CLI precedence; both corrected, with the precedence
ladder written out and the override exception stated explicitly. PR body
rewritten — it still named ~/.local/state, claimed state survives
deleting ~/.hyperframes, and carried stale counts.
Tests: 2330 CLI (bundle absent), 3151 Studio, 24 core. Fault injection:
reverting each fix alone fails 5 CLI / 5 Studio.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
836 lines
31 KiB
TypeScript
836 lines
31 KiB
TypeScript
/**
|
|
* Embedded studio server for `hyperframes preview` outside the monorepo.
|
|
*
|
|
* Uses the shared studio API module from @hyperframes/core/studio-api,
|
|
* providing a CLI-specific adapter for single-project, in-process rendering.
|
|
*/
|
|
|
|
import { Hono, type Context } from "hono";
|
|
import { streamSSE } from "hono/streaming";
|
|
import { existsSync, readFileSync, writeFileSync, statSync, unlinkSync } from "node:fs";
|
|
import { resolve, join, basename } from "node:path";
|
|
import { createProjectWatcher, type ProjectWatcher } from "./fileWatcher.js";
|
|
import {
|
|
hashSignatureParts,
|
|
loadRuntimeSource,
|
|
loadRuntimeSourceSignature,
|
|
} from "./runtimeSource.js";
|
|
import { VERSION as version } from "../version.js";
|
|
import {
|
|
buildStudioHeadScriptsForHost,
|
|
isLoopbackHost,
|
|
resolveCliTelemetryDistinctId,
|
|
} from "./telemetryIdentity.js";
|
|
import { emitStudioRenderComplete, emitStudioRenderError } from "./studioRenderTelemetry.js";
|
|
import { isDevMode } from "../utils/env.js";
|
|
import {
|
|
createStudioManualEditsRenderBodyScript,
|
|
createStudioApi,
|
|
createProjectSignature,
|
|
createBackgroundRemovalJob,
|
|
consumeFileWriteReceipt,
|
|
getMimeType,
|
|
type PreviewApiAdapter,
|
|
type ResolvedProject,
|
|
type RenderJobState,
|
|
type BackgroundRemovalRender,
|
|
} from "@hyperframes/studio-server";
|
|
import { resolveAutoProxy } from "../utils/projectConfig.js";
|
|
import { getElementScreenshotClip } from "@hyperframes/studio-server/screenshot-clip";
|
|
import type { ScreenshotClip } from "@hyperframes/studio-server/screenshot-clip";
|
|
import type { RenderJob } from "@hyperframes/producer";
|
|
|
|
const STUDIO_MANUAL_EDITS_PATH = ".hyperframes/studio-manual-edits.json";
|
|
const REMOTE_GIF_IMG_SRC_RE =
|
|
/<img\b[^>]*?\bsrc\s*=\s*["'](https?:\/\/[^"']+\.gif(?:[?#][^"']*)?)["'][^>]*>/gi;
|
|
|
|
async function loadStudioProducer() {
|
|
return isDevMode()
|
|
? await import("../../../producer/src/index.js")
|
|
: await import("@hyperframes/producer");
|
|
}
|
|
|
|
// ── Path resolution ─────────────────────────────────────────────────────────
|
|
|
|
function resolveDistDir(): string {
|
|
return resolveStudioBundle().dir;
|
|
}
|
|
|
|
export interface StudioBundleResolution {
|
|
dir: string;
|
|
indexPath: string;
|
|
available: boolean;
|
|
checkedPaths: string[];
|
|
}
|
|
|
|
export function resolveStudioBundle(): StudioBundleResolution {
|
|
const builtPath = resolve(__dirname, "studio");
|
|
const builtIndex = resolve(builtPath, "index.html");
|
|
if (existsSync(builtIndex)) {
|
|
return { dir: builtPath, indexPath: builtIndex, available: true, checkedPaths: [builtIndex] };
|
|
}
|
|
const devPath = resolve(__dirname, "..", "..", "..", "studio", "dist");
|
|
const devIndex = resolve(devPath, "index.html");
|
|
if (existsSync(devIndex)) {
|
|
return {
|
|
dir: devPath,
|
|
indexPath: devIndex,
|
|
available: true,
|
|
checkedPaths: [builtIndex, devIndex],
|
|
};
|
|
}
|
|
return {
|
|
dir: builtPath,
|
|
indexPath: builtIndex,
|
|
available: false,
|
|
checkedPaths: [builtIndex, devIndex],
|
|
};
|
|
}
|
|
|
|
function resolveRuntimePath(): string {
|
|
const builtPath = resolve(__dirname, "hyperframe-runtime.js");
|
|
if (existsSync(builtPath)) return builtPath;
|
|
const iifePath = resolve(__dirname, "hyperframe.runtime.iife.js");
|
|
if (existsSync(iifePath)) return iifePath;
|
|
const devPath = resolve(
|
|
__dirname,
|
|
"..",
|
|
"..",
|
|
"..",
|
|
"core",
|
|
"dist",
|
|
"hyperframe.runtime.iife.js",
|
|
);
|
|
if (existsSync(devPath)) return devPath;
|
|
return builtPath;
|
|
}
|
|
|
|
function readStudioManualEditManifestContent(projectDir: string): string {
|
|
const manifestPath = join(projectDir, STUDIO_MANUAL_EDITS_PATH);
|
|
if (!existsSync(manifestPath)) return "";
|
|
try {
|
|
return readFileSync(manifestPath, "utf-8");
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
async function applyStudioManualEditsToThumbnailPage(
|
|
page: import("puppeteer-core").Page,
|
|
manifestContent: string,
|
|
activeCompositionPath: string,
|
|
): Promise<void> {
|
|
const script = createStudioManualEditsRenderBodyScript(manifestContent, {
|
|
activeCompositionPath,
|
|
});
|
|
if (!script) return;
|
|
await page.addScriptTag({ content: script });
|
|
}
|
|
|
|
async function reapplyStudioManualEditsToThumbnailPage(
|
|
page: import("puppeteer-core").Page,
|
|
): Promise<void> {
|
|
await page.evaluate(() => {
|
|
const apply = (window as Window & { __hfStudioManualEditsApply?: () => number })
|
|
.__hfStudioManualEditsApply;
|
|
if (typeof apply === "function") apply();
|
|
});
|
|
}
|
|
|
|
function collectRemoteGifImageSources(html: string): string[] {
|
|
const urls = new Set<string>();
|
|
const re = new RegExp(REMOTE_GIF_IMG_SRC_RE.source, REMOTE_GIF_IMG_SRC_RE.flags);
|
|
let match: RegExpExecArray | null;
|
|
while ((match = re.exec(html)) !== null) {
|
|
if (match[1]) urls.add(match[1]);
|
|
}
|
|
return [...urls];
|
|
}
|
|
|
|
async function downloadRemoteGifImageSources(
|
|
html: string,
|
|
downloadDir: string,
|
|
downloadToTemp: (url: string, destDir: string) => Promise<string>,
|
|
): Promise<Map<string, string>> {
|
|
const sourceAssets = new Map<string, string>();
|
|
await Promise.all(
|
|
collectRemoteGifImageSources(html).map(async (url) => {
|
|
try {
|
|
sourceAssets.set(url, await downloadToTemp(url, downloadDir));
|
|
} catch (err) {
|
|
console.warn(
|
|
"[Studio] Remote animated GIF prep skipped:",
|
|
err instanceof Error ? err.message : err,
|
|
);
|
|
}
|
|
}),
|
|
);
|
|
return sourceAssets;
|
|
}
|
|
|
|
// ── Shared thumbnail browser (pool-backed) ──────────────────────────────────
|
|
// Uses the engine's browser pool so the thumbnail browser and render workers
|
|
// share a single Chrome process instead of running two independent ones.
|
|
|
|
let _thumbnailBrowserLease: import("@hyperframes/engine").BrowserLease | null = null;
|
|
let _thumbnailBrowserInitializing: Promise<
|
|
import("@hyperframes/engine").BrowserLease | null
|
|
> | null = null;
|
|
|
|
async function getThumbnailBrowser(): Promise<import("puppeteer-core").Browser | null> {
|
|
if (_thumbnailBrowserLease?.browser.connected) return _thumbnailBrowserLease.browser;
|
|
if (_thumbnailBrowserInitializing) {
|
|
return (await _thumbnailBrowserInitializing)?.browser ?? null;
|
|
}
|
|
|
|
_thumbnailBrowserInitializing = (async () => {
|
|
try {
|
|
const { ensureBrowser } = await import("../browser/manager.js");
|
|
const { acquireBrowser, buildChromeArgs } = await import("@hyperframes/engine");
|
|
|
|
try {
|
|
const b = await ensureBrowser({ preferManagedChrome: true });
|
|
if (b.executablePath && !process.env.PRODUCER_HEADLESS_SHELL_PATH) {
|
|
process.env.PRODUCER_HEADLESS_SHELL_PATH = b.executablePath;
|
|
}
|
|
} catch {
|
|
/* continue — acquireBrowser will try its own resolution */
|
|
}
|
|
|
|
const acquired = await acquireBrowser(
|
|
buildChromeArgs({ width: 1920, height: 1080, captureMode: "screenshot" }),
|
|
{ forceScreenshot: true },
|
|
);
|
|
_thumbnailBrowserLease = acquired;
|
|
acquired.browser.on("disconnected", () => {
|
|
if (_thumbnailBrowserLease === acquired) _thumbnailBrowserLease = null;
|
|
_thumbnailBrowserInitializing = null;
|
|
});
|
|
return acquired;
|
|
} catch (err) {
|
|
console.warn(
|
|
"[Studio] Failed to launch thumbnail browser:",
|
|
err instanceof Error ? err.message : err,
|
|
);
|
|
_thumbnailBrowserInitializing = null;
|
|
return null;
|
|
}
|
|
})();
|
|
|
|
return (await _thumbnailBrowserInitializing)?.browser ?? null;
|
|
}
|
|
|
|
export async function closeThumbnailBrowser(): Promise<void> {
|
|
if (!_thumbnailBrowserLease) return;
|
|
const lease = _thumbnailBrowserLease;
|
|
_thumbnailBrowserLease = null;
|
|
_thumbnailBrowserInitializing = null;
|
|
await lease.release().catch(() => {});
|
|
}
|
|
|
|
// ── Server factory ──────────────────────────────────────────────────────────
|
|
|
|
export interface StudioServerOptions {
|
|
projectDir: string;
|
|
/** Display name for the project. Defaults to basename of projectDir. */
|
|
projectName?: string;
|
|
/**
|
|
* Auto-transcode browser-hostile video codecs to a cached H.264 preview
|
|
* proxy. The preview command passes its resolved `--proxy`/`--no-proxy` +
|
|
* `hyperframes.json` value; when omitted, the project's `media.autoProxy`
|
|
* config (default true) applies.
|
|
*/
|
|
autoProxy?: boolean | undefined;
|
|
}
|
|
|
|
export interface StudioServer {
|
|
app: Hono;
|
|
watcher: ProjectWatcher;
|
|
/** Exposed for tests: the adapter handed to the shared studio API (carries
|
|
* the resolved `autoProxy` flag the preview routes read). */
|
|
adapter: PreviewApiAdapter;
|
|
}
|
|
|
|
export async function loadPreviewServerBuildSignature(): Promise<string> {
|
|
const runtimeSignature = await loadRuntimeSourceSignature();
|
|
const studioBundle = resolveStudioBundle();
|
|
const studioIndex =
|
|
studioBundle.available && existsSync(studioBundle.indexPath)
|
|
? readFileSync(studioBundle.indexPath, "utf-8")
|
|
: "";
|
|
return hashSignatureParts([
|
|
version,
|
|
runtimeSignature,
|
|
studioIndex,
|
|
createStudioServer.toString(),
|
|
createStudioApi.toString(),
|
|
createProjectSignature.toString(),
|
|
getMimeType.toString(),
|
|
getElementScreenshotClip.toString(),
|
|
]);
|
|
}
|
|
|
|
// Rewrite the viewport meta + inline width/height in every written .html to the
|
|
// host composition's dimensions, so an installed fragment matches the host
|
|
// canvas. Applies to ALL written files — including any .html a dependency ships,
|
|
// not just the requested block's — which is intentional. No-op when the host
|
|
// index.html is absent or carries no dimensions.
|
|
function rewriteWrittenToHostViewport(projectDir: string, written: string[]): void {
|
|
const indexPath = join(projectDir, "index.html");
|
|
if (!existsSync(indexPath)) return;
|
|
const indexHtml = readFileSync(indexPath, "utf-8");
|
|
const hostW = indexHtml.match(/data-width="(\d+)"/)?.[1];
|
|
const hostH = indexHtml.match(/data-height="(\d+)"/)?.[1];
|
|
if (!hostW || !hostH) return;
|
|
|
|
for (const absPath of written) {
|
|
if (!absPath.endsWith(".html")) continue;
|
|
let content = readFileSync(absPath, "utf-8");
|
|
content = content.replace(
|
|
/(<meta\s+name="viewport"\s+content="width=)\d+(,\s*height=)\d+/i,
|
|
`$1${hostW}$2${hostH}`,
|
|
);
|
|
content = content.replace(
|
|
/(\bwidth:\s*)\d+(px;\s*\n?\s*height:\s*)\d+(px;)/g,
|
|
(match, pre, mid, post) => {
|
|
if (match.includes("1920") || match.includes("1080")) {
|
|
return `${pre}${hostW}${mid}${hostH}${post}`;
|
|
}
|
|
return match;
|
|
},
|
|
);
|
|
writeFileSync(absPath, content, "utf-8");
|
|
}
|
|
}
|
|
|
|
export function createStudioServer(options: StudioServerOptions): StudioServer {
|
|
const { projectDir, projectName } = options;
|
|
const projectId = projectName || basename(projectDir);
|
|
const studioDir = resolveDistDir();
|
|
const runtimePath = resolveRuntimePath();
|
|
const watcher = createProjectWatcher(projectDir);
|
|
|
|
// ── CLI adapter for the shared studio API ──────────────────────────────
|
|
|
|
const project: ResolvedProject = { id: projectId, dir: projectDir, title: projectId };
|
|
let cachedProjectSignature: string | null = null;
|
|
watcher.addListener(() => {
|
|
cachedProjectSignature = null;
|
|
});
|
|
|
|
const adapter: PreviewApiAdapter = {
|
|
// Explicit option wins (preview's resolved --proxy/--no-proxy + config);
|
|
// otherwise honor the project's hyperframes.json media.autoProxy so every
|
|
// createStudioServer caller (e.g. the background preview child) gets the
|
|
// configured behavior without its own plumbing.
|
|
autoProxy: options.autoProxy ?? resolveAutoProxy(projectDir, undefined),
|
|
|
|
listProjects: () => [project],
|
|
|
|
resolveProject: (id: string) => (id === projectId ? project : null),
|
|
|
|
async bundle(dir: string): Promise<string | null> {
|
|
try {
|
|
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
|
|
// Studio dev server: ask the bundler for an empty `src=""` placeholder so
|
|
// we can point it at our hot-reloadable local runtime endpoint. Inlining
|
|
// ~150 KB of runtime body on every preview render would defeat browser
|
|
// caching across composition edits.
|
|
let html = await bundleToSingleHtml(dir, {
|
|
runtime: "placeholder",
|
|
inlineColorGradingLuts: false,
|
|
});
|
|
html = html.replace(
|
|
'data-hyperframes-preview-runtime="1" src=""',
|
|
'data-hyperframes-preview-runtime="1" src="/api/runtime.js"',
|
|
);
|
|
return html;
|
|
} catch (err) {
|
|
console.error("[studio] Bundle failed:", err);
|
|
return null;
|
|
}
|
|
},
|
|
|
|
async transformPreviewHtml({ html, project }) {
|
|
const { injectDeterministicFontFaces } =
|
|
await import("../../../producer/src/services/deterministicFonts.js");
|
|
const { prepareAnimatedGifInputs } =
|
|
await import("../../../producer/src/services/animatedGifPrep.js");
|
|
const { downloadToTemp } = await import("../../../producer/src/utils/urlDownloader.js");
|
|
const gifOutputDir = join(project.dir, ".hyperframes", "prepared-assets", "gif");
|
|
const gifDownloadDir = join(project.dir, ".hyperframes", "prepared-assets", "downloads");
|
|
const prepared = await prepareAnimatedGifInputs(html, {
|
|
projectDir: project.dir,
|
|
downloadDir: gifDownloadDir,
|
|
outputDir: gifOutputDir,
|
|
outputSrcPrefix: ".hyperframes/prepared-assets/gif",
|
|
cacheDir: gifOutputDir,
|
|
sourceAssets: await downloadRemoteGifImageSources(html, gifDownloadDir, downloadToTemp),
|
|
});
|
|
return injectDeterministicFontFaces(prepared.html);
|
|
},
|
|
|
|
getProjectSignature(dir: string): string {
|
|
if (resolve(dir) !== resolve(projectDir)) return createProjectSignature(dir);
|
|
cachedProjectSignature ??= createProjectSignature(projectDir);
|
|
return cachedProjectSignature;
|
|
},
|
|
|
|
async lint(html: string, opts?: { filePath?: string }) {
|
|
const { lintHyperframeHtml } = await import("@hyperframes/lint");
|
|
return await lintHyperframeHtml(html, opts);
|
|
},
|
|
|
|
runtimeUrl: "/api/runtime.js",
|
|
|
|
rendersDir: () => join(projectDir, "renders"),
|
|
|
|
startRender(opts): RenderJobState {
|
|
const abortController = new AbortController();
|
|
const state: RenderJobState = {
|
|
id: opts.jobId,
|
|
status: "rendering",
|
|
progress: 0,
|
|
outputPath: opts.outputPath,
|
|
cancel: () => abortController.abort(),
|
|
};
|
|
|
|
// Run render asynchronously, mutating the state object
|
|
const startTime = Date.now();
|
|
(async () => {
|
|
let renderJob: RenderJob | undefined;
|
|
const removeCancelledOutput = () => {
|
|
// User-initiated cancel: not a failure. Remove any output so the
|
|
// cancelled job doesn't resurrect in the render history.
|
|
state.status = "cancelled";
|
|
for (const suffix of ["", ".meta.json"]) {
|
|
const fp = suffix
|
|
? opts.outputPath.replace(/\.(mp4|webm|mov)$/, suffix)
|
|
: opts.outputPath;
|
|
try {
|
|
if (existsSync(fp)) unlinkSync(fp);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
};
|
|
try {
|
|
const { createRenderJob, executeRenderJob } = await loadStudioProducer();
|
|
const { ensureBrowser } = await import("../browser/manager.js");
|
|
|
|
try {
|
|
const browser = await ensureBrowser({ preferManagedChrome: true });
|
|
if (browser.executablePath && !process.env.PRODUCER_HEADLESS_SHELL_PATH) {
|
|
process.env.PRODUCER_HEADLESS_SHELL_PATH = browser.executablePath;
|
|
}
|
|
} catch {
|
|
// Continue without — acquireBrowser will try its own resolution
|
|
}
|
|
|
|
const manifestContent = readStudioManualEditManifestContent(opts.project.dir);
|
|
const manualEditsRenderScript = createStudioManualEditsRenderBodyScript(manifestContent);
|
|
const job = createRenderJob({
|
|
// opts.fps is already an Fps rational — see vite-config-studio
|
|
// adapter for the same convention.
|
|
fps: opts.fps,
|
|
quality: opts.quality as "draft" | "standard" | "high",
|
|
format: opts.format,
|
|
outputResolution: opts.outputResolution,
|
|
...(manualEditsRenderScript ? { renderBodyScripts: [manualEditsRenderScript] } : {}),
|
|
...(opts.composition ? { entryFile: opts.composition } : {}),
|
|
...(opts.variables ? { variables: opts.variables } : {}),
|
|
});
|
|
renderJob = job;
|
|
const onProgress = (j: { progress: number; currentStage?: string }) => {
|
|
state.progress = j.progress;
|
|
if (j.currentStage) state.stage = j.currentStage;
|
|
};
|
|
await executeRenderJob(
|
|
job,
|
|
opts.project.dir,
|
|
opts.outputPath,
|
|
onProgress,
|
|
abortController.signal,
|
|
);
|
|
if (abortController.signal.aborted) {
|
|
// Cancel landed just as the render finished: honor the cancel the
|
|
// route already reported instead of resurrecting a completed job.
|
|
removeCancelledOutput();
|
|
return;
|
|
}
|
|
state.status = "complete";
|
|
state.progress = 100;
|
|
const metaPath = opts.outputPath.replace(/\.(mp4|webm|mov)$/, ".meta.json");
|
|
writeFileSync(
|
|
metaPath,
|
|
JSON.stringify({ status: "complete", durationMs: Date.now() - startTime }),
|
|
);
|
|
emitStudioRenderComplete(opts, Date.now() - startTime, job.perfSummary);
|
|
} catch (err) {
|
|
if (abortController.signal.aborted) {
|
|
removeCancelledOutput();
|
|
return;
|
|
}
|
|
state.status = "failed";
|
|
state.error = err instanceof Error ? err.message : String(err);
|
|
// fallow-ignore-next-line code-duplication
|
|
emitStudioRenderError(opts, Date.now() - startTime, state.stage, err, renderJob);
|
|
try {
|
|
const metaPath = opts.outputPath.replace(/\.(mp4|webm|mov)$/, ".meta.json");
|
|
writeFileSync(metaPath, JSON.stringify({ status: "failed" }));
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
})();
|
|
|
|
return state;
|
|
},
|
|
|
|
startBackgroundRemoval(opts) {
|
|
return createBackgroundRemovalJob(opts, async (renderOpts) => {
|
|
const sourcePipelinePath = "../background-removal/pipeline.ts";
|
|
const pipeline = (await import("../background-removal/pipeline.js").catch(
|
|
() => import(sourcePipelinePath),
|
|
)) as { render: BackgroundRemovalRender };
|
|
return pipeline.render(renderOpts);
|
|
});
|
|
},
|
|
|
|
async generateThumbnail(opts): Promise<Buffer | null> {
|
|
const browser = await getThumbnailBrowser();
|
|
if (!browser) {
|
|
console.warn("[Studio] Thumbnail: no browser available — Chrome may not be installed");
|
|
return null;
|
|
}
|
|
let page: import("puppeteer-core").Page | null = null;
|
|
try {
|
|
page = await browser.newPage();
|
|
await page.setViewport({ width: opts.width || 1920, height: opts.height || 1080 });
|
|
await page.goto(opts.previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 });
|
|
await page
|
|
.waitForFunction(
|
|
() => {
|
|
const w = window as Window & {
|
|
__timelines?: Record<string, unknown>;
|
|
};
|
|
return !!(w.__timelines && Object.keys(w.__timelines).length > 0);
|
|
},
|
|
{ timeout: 5000 },
|
|
)
|
|
.catch(() => {});
|
|
// fallow-ignore-next-line code-duplication
|
|
await page.evaluate((t: number) => {
|
|
const w = window as Window & {
|
|
__player?: { seek?: (time: number) => void };
|
|
__timelines?: Record<string, { pause?: (time?: number) => void }>;
|
|
gsap?: { ticker?: { tick?: () => void } };
|
|
};
|
|
if (typeof w.__player?.seek === "function") {
|
|
w.__player.seek(t);
|
|
} else if (w.__timelines) {
|
|
for (const tl of Object.values(w.__timelines)) {
|
|
tl?.pause?.(t);
|
|
}
|
|
w.gsap?.ticker?.tick?.();
|
|
}
|
|
}, opts.seekTime);
|
|
const manifestContent = readStudioManualEditManifestContent(opts.project.dir);
|
|
await applyStudioManualEditsToThumbnailPage(page, manifestContent, opts.compPath);
|
|
await page.evaluate(() => {
|
|
void document.fonts?.ready;
|
|
const body = document.body;
|
|
if (body && getComputedStyle(body).backgroundColor === "rgba(0, 0, 0, 0)") {
|
|
body.style.backgroundColor = "#1c2028";
|
|
}
|
|
});
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
await reapplyStudioManualEditsToThumbnailPage(page);
|
|
let clip: ScreenshotClip | undefined;
|
|
if (opts.selector) {
|
|
clip = await page.evaluate(getElementScreenshotClip, opts.selector, opts.selectorIndex);
|
|
}
|
|
const screenshot = (await page.screenshot(
|
|
opts.format === "png"
|
|
? {
|
|
type: "png",
|
|
...(clip ? { clip } : {}),
|
|
}
|
|
: {
|
|
type: "jpeg",
|
|
quality: 80,
|
|
...(clip ? { clip } : {}),
|
|
},
|
|
)) as Buffer;
|
|
return screenshot;
|
|
} catch (err) {
|
|
console.warn(
|
|
"[Studio] Thumbnail generation failed:",
|
|
err instanceof Error ? err.message : err,
|
|
);
|
|
return null;
|
|
} finally {
|
|
await page?.close().catch(() => {});
|
|
}
|
|
},
|
|
|
|
async listRegistryCatalog() {
|
|
const { listRegistryItems, loadAllItems } = await import("../registry/resolver.js");
|
|
const entries = await listRegistryItems();
|
|
const blockAndComponentEntries = entries.filter(
|
|
(e) => e.type === "hyperframes:block" || e.type === "hyperframes:component",
|
|
);
|
|
return loadAllItems(blockAndComponentEntries);
|
|
},
|
|
|
|
async installRegistryBlock(opts) {
|
|
const { resolveItemWithDependencies } = await import("../registry/resolver.js");
|
|
const { installItem } = await import("../registry/installer.js");
|
|
const { gateRegistryItemsCompatibility } = await import("../registry/compatibility.js");
|
|
// Resolve transitive registryDependencies and install them first so a
|
|
// block that depends on other registry items installs completely.
|
|
const items = await resolveItemWithDependencies(opts.blockName);
|
|
// Compatibility-gate the whole set before writing anything (same gate as
|
|
// `hyperframes add`), so an incompatible block or dep aborts cleanly.
|
|
const warnings = gateRegistryItemsCompatibility(items);
|
|
for (const warning of warnings) {
|
|
process.stderr.write(`hyperframes:registry ${warning}\n`);
|
|
}
|
|
const written: string[] = [];
|
|
for (const dep of items) {
|
|
const result = await installItem(dep, { destDir: opts.project.dir });
|
|
written.push(...result.written);
|
|
}
|
|
const item = items[items.length - 1]!;
|
|
|
|
rewriteWrittenToHostViewport(opts.project.dir, written);
|
|
|
|
const relativePaths = written.map((abs) => {
|
|
const rel = abs.startsWith(opts.project.dir) ? abs.slice(opts.project.dir.length + 1) : abs;
|
|
return rel;
|
|
});
|
|
return { written: relativePaths, block: item };
|
|
},
|
|
};
|
|
|
|
// ── Build the Hono app ─────────────────────────────────────────────────
|
|
|
|
const app = new Hono();
|
|
|
|
// Config probe endpoint — used by port detection to identify existing
|
|
// HyperFrames instances and reuse them instead of spawning duplicates.
|
|
// See portUtils.ts detectHyperframesServer() for the consumer.
|
|
app.get("/__hyperframes_config", (c) => {
|
|
const serve = async () => {
|
|
const serverBuildSignature = await loadPreviewServerBuildSignature();
|
|
return c.json({
|
|
isHyperframes: true,
|
|
pid: process.pid,
|
|
projectName: projectId,
|
|
projectDir: projectDir,
|
|
serverBuildSignature,
|
|
version,
|
|
});
|
|
};
|
|
return serve();
|
|
});
|
|
|
|
// CLI-specific routes (before shared API)
|
|
app.get("/api/runtime.js", (c) => {
|
|
const serve = async () => {
|
|
const runtimeSource =
|
|
(await loadRuntimeSource()) ??
|
|
(existsSync(runtimePath) ? readFileSync(runtimePath, "utf-8") : null);
|
|
if (!runtimeSource) return c.text("runtime not available", 404);
|
|
return c.body(runtimeSource, 200, {
|
|
"Content-Type": "text/javascript",
|
|
"Cache-Control": "no-store",
|
|
});
|
|
};
|
|
return serve();
|
|
});
|
|
|
|
// CLI → Studio telemetry identity endpoint (Layer 1). Studio reads the
|
|
// injected `window.__HF_CLI_DISTINCT_ID` first; this GET is a fallback for
|
|
// clients that can't rely on the injected global. Returns the CLI's anonymous
|
|
// distinct id (no PII) so the browser session can join the CLI's PostHog
|
|
// person, or `{ distinctId: null }` when CLI telemetry is disabled.
|
|
//
|
|
// Deliberately does NOT serve `bucketSeed`. Studio gets its canary answers
|
|
// from the injected `window.__HF_CLI_CANARY_DECISIONS` (booleans, not the
|
|
// value cohorts derive from), so nothing needs the seed over HTTP — and an
|
|
// unauthenticated local endpoint is a strictly worse place for it than an
|
|
// inline script scoped to Studio's own document.
|
|
//
|
|
// Host-guarded against DNS rebinding: a remote page can point a hostname it
|
|
// controls at 127.0.0.1 and read this response as same-origin. Pinning the
|
|
// Host header to a loopback name means such a request (which carries the
|
|
// attacker's hostname) is refused. Same-origin Studio traffic always
|
|
// presents the bound loopback host.
|
|
app.get("/api/telemetry-identity", (c) => {
|
|
if (!isLoopbackHost(c.req.header("host"))) {
|
|
return c.json({ error: "forbidden" }, 403);
|
|
}
|
|
return c.json({ distinctId: resolveCliTelemetryDistinctId() });
|
|
});
|
|
|
|
app.get("/api/events", (c) => {
|
|
return streamSSE(c, async (stream) => {
|
|
const listener = (path: string) => {
|
|
const receipt = consumeFileWriteReceipt(resolve(projectDir, path));
|
|
stream
|
|
.writeSSE({ event: "file-change", data: JSON.stringify(receipt ?? { path }) })
|
|
.catch(() => {});
|
|
};
|
|
watcher.addListener(listener);
|
|
while (true) {
|
|
await stream.sleep(30000);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── Pre-flight checks for render ────────────────────────────────────────
|
|
// Intercept render requests before they reach the shared API so we can
|
|
// fail fast with an actionable hint instead of burning through the entire
|
|
// capture pipeline before hitting "spawn ffmpeg ENOENT" at encode.
|
|
let cachedFFmpegPath: string | undefined;
|
|
app.post("/api/projects/:id/render", async (c, next) => {
|
|
const { findFFmpeg, getFFmpegInstallHint } = await import("../browser/ffmpeg.js");
|
|
if (!cachedFFmpegPath) {
|
|
cachedFFmpegPath = findFFmpeg();
|
|
}
|
|
if (!cachedFFmpegPath) {
|
|
return c.json({ error: "FFmpeg not found", hint: getFFmpegInstallHint() }, 503);
|
|
}
|
|
return next();
|
|
});
|
|
|
|
// Mount the shared studio API at /api.
|
|
// Use fetch() forwarding (not .route()) so the sub-app sees paths without
|
|
// the /api prefix — the shared module's path extraction uses c.req.path.
|
|
const api = createStudioApi(adapter);
|
|
app.all("/api/*", async (c) => {
|
|
const url = new URL(c.req.url);
|
|
url.pathname = url.pathname.slice(4); // Strip "/api" prefix
|
|
const forwardReq = new Request(url.toString(), {
|
|
method: c.req.method,
|
|
headers: c.req.raw.headers,
|
|
body: c.req.raw.body,
|
|
// @ts-expect-error -- Node needs duplex for streaming bodies
|
|
duplex: "half",
|
|
});
|
|
return api.fetch(forwardReq);
|
|
});
|
|
|
|
// Studio SPA static files
|
|
const serveStudioStaticFile = (c: Context) => {
|
|
const filePath = resolve(studioDir, c.req.path.slice(1));
|
|
if (!existsSync(filePath) || !statSync(filePath).isFile()) return c.text("not found", 404);
|
|
const content = readFileSync(filePath);
|
|
return new Response(content, {
|
|
headers: { "Content-Type": getMimeType(filePath), "Cache-Control": "no-store" },
|
|
});
|
|
};
|
|
app.get("/assets/*", serveStudioStaticFile);
|
|
app.get("/icons/*", serveStudioStaticFile);
|
|
app.get("/favicon.svg", serveStudioStaticFile);
|
|
|
|
// ── Runtime env injection ───────────────────────────────────────────────
|
|
// When the studio is served as a pre-built SPA, Vite `VITE_STUDIO_*` env
|
|
// vars were baked at build time. Collect any such vars from the current
|
|
// process.env and inject them as `window.__HF_STUDIO_ENV__` so the client
|
|
// can pick them up at runtime, overriding the baked defaults.
|
|
function buildRuntimeEnvScript(): string {
|
|
const overrides: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(process.env)) {
|
|
if (key.startsWith("VITE_STUDIO_") && value !== undefined) {
|
|
overrides[key] = value;
|
|
}
|
|
}
|
|
if (Object.keys(overrides).length === 0) return "";
|
|
return `<script>window.__HF_STUDIO_ENV__=${JSON.stringify(overrides)};</script>`;
|
|
}
|
|
|
|
// SPA fallback
|
|
app.get("*", (c) => {
|
|
const indexPath = resolve(studioDir, "index.html");
|
|
if (!existsSync(indexPath)) {
|
|
return c.html(
|
|
`<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>HyperFrames Studio unavailable</title>
|
|
<style>
|
|
body {
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
display: grid;
|
|
place-items: center;
|
|
background: #0d0f14;
|
|
color: #eef2f7;
|
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
}
|
|
main {
|
|
width: min(560px, calc(100vw - 48px));
|
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
border-radius: 8px;
|
|
padding: 28px;
|
|
background: #151923;
|
|
}
|
|
h1 {
|
|
margin: 0 0 12px;
|
|
font-size: 22px;
|
|
line-height: 1.2;
|
|
}
|
|
p {
|
|
margin: 0 0 18px;
|
|
color: #aab3c2;
|
|
line-height: 1.5;
|
|
}
|
|
code {
|
|
display: block;
|
|
padding: 12px 14px;
|
|
border-radius: 6px;
|
|
background: #090b10;
|
|
color: #8ff0c2;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<h1>Studio bundle missing</h1>
|
|
<p>The preview server started, but this CLI build does not contain the Studio assets.</p>
|
|
<code>bun run build</code>
|
|
</main>
|
|
</body>
|
|
</html>`,
|
|
500,
|
|
);
|
|
}
|
|
let html = readFileSync(indexPath, "utf-8");
|
|
// Inject before the studio bundle runs. Identity script first (see
|
|
// buildStudioHeadScripts) so the CLI distinct id is on `window` by the time
|
|
// telemetry init reads it.
|
|
//
|
|
// Host-guarded for the same reason /api/telemetry-identity is, and it has
|
|
// to be checked HERE too: guarding only the endpoint leaves this route as
|
|
// an open side door, since a rebound origin can simply fetch `/` and read
|
|
// the same distinct id and seed out of the returned HTML.
|
|
//
|
|
// Only IDENTITY is withheld from an untrusted Host. The canary decisions
|
|
// map still goes out — it is non-identifying, and a LAN/remote Studio
|
|
// (`HYPERFRAMES_PREVIEW_HOST=0.0.0.0`) needs it to stay in agreement with
|
|
// the CLI. See buildStudioHeadScriptsForHost.
|
|
const headScript = buildStudioHeadScriptsForHost(buildRuntimeEnvScript(), c.req.header("host"));
|
|
if (headScript) {
|
|
html = html.replace("<head>", `<head>${headScript}`);
|
|
}
|
|
return c.html(html);
|
|
});
|
|
|
|
return { app, watcher, adapter };
|
|
}
|