Files
hyperframes/packages/cli/src/server/studioServer.ts
T
Miguel Ángel 2a7a5e6236 feat(preview): keep review sessions alive (#2384)
* feat(preview): keep review sessions alive

* fix(preview): harden background lifecycle state

* refactor(preview): satisfy lifecycle quality gate
2026-07-13 22:55:19 -04:00

783 lines
29 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 { buildStudioHeadScripts, resolveCliTelemetryDistinctId } from "./telemetryIdentity.js";
import { emitStudioRenderComplete, emitStudioRenderError } from "./studioRenderTelemetry.js";
import { isDevMode } from "../utils/env.js";
import {
createStudioManualEditsRenderBodyScript,
createStudioApi,
createProjectSignature,
createBackgroundRemovalJob,
getMimeType,
type StudioApiAdapter,
type ResolvedProject,
type RenderJobState,
type BackgroundRemovalRender,
} from "@hyperframes/studio-server";
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 _thumbnailBrowser: import("puppeteer-core").Browser | null = null;
let _thumbnailBrowserInitializing: Promise<import("puppeteer-core").Browser | null> | null = null;
async function getThumbnailBrowser(): Promise<import("puppeteer-core").Browser | null> {
if (_thumbnailBrowser?.connected) return _thumbnailBrowser;
if (_thumbnailBrowserInitializing) return _thumbnailBrowserInitializing;
_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 },
);
_thumbnailBrowser = acquired.browser;
_thumbnailBrowser.on("disconnected", () => {
_thumbnailBrowser = null;
_thumbnailBrowserInitializing = null;
});
return _thumbnailBrowser;
} catch (err) {
console.warn(
"[Studio] Failed to launch thumbnail browser:",
err instanceof Error ? err.message : err,
);
_thumbnailBrowserInitializing = null;
return null;
}
})();
return _thumbnailBrowserInitializing;
}
export async function closeThumbnailBrowser(): Promise<void> {
if (!_thumbnailBrowser) return;
const browser = _thumbnailBrowser;
_thumbnailBrowser = null;
_thumbnailBrowserInitializing = null;
const { releaseBrowser } = await import("@hyperframes/engine");
await releaseBrowser(browser).catch(() => {});
}
// ── Server factory ──────────────────────────────────────────────────────────
export interface StudioServerOptions {
projectDir: string;
/** Display name for the project. Defaults to basename of projectDir. */
projectName?: string;
}
export interface StudioServer {
app: Hono;
watcher: ProjectWatcher;
}
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: StudioApiAdapter = {
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.
app.get("/api/telemetry-identity", (c) => {
return c.json({ distinctId: resolveCliTelemetryDistinctId() });
});
app.get("/api/events", (c) => {
return streamSSE(c, async (stream) => {
const listener = (path: string) => {
stream.writeSSE({ event: "file-change", data: JSON.stringify({ 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.
const headScript = buildStudioHeadScripts(buildRuntimeEnvScript());
if (headScript) {
html = html.replace("<head>", `<head>${headScript}`);
}
return c.html(html);
});
return { app, watcher };
}