Files
hyperframes/packages/studio/vite.config.ts
T
James 534c70e308 fix(studio): keep dev server alive when puppeteer thumbnail launch fails
Two bugs in getSharedBrowser() could take down the entire Vite dev
server:

1. Unhandled rejection from puppeteer.launch() — the timeout error
   surfaces through puppeteer's internal RxJS chain, and any uncaught
   path crashes the Node process. The thumbnail route's try/catch
   doesn't always intercept it.
2. _browserLaunchPromise was never reset on failure, so subsequent
   thumbnail requests reused a stale rejected promise instead of
   retrying.

Wrap the IIFE in try/catch, return null on any failure (the thumbnail
route already handles a null adapter result with a 500), and reset
_browserLaunchPromise in a finally block so a transient launch failure
doesn't poison the singleton. Also drop the launch timeout from
puppeteer's 30s default to 10s so a wedged handshake fails fast instead
of stalling every pending thumbnail.

Verified locally: the dev server now logs
"[Studio] puppeteer launch failed — thumbnails disabled: ..." and
keeps serving the studio UI after a thumbnail request fails.
2026-05-11 15:55:38 +00:00

571 lines
20 KiB
TypeScript

import { defineConfig, type Plugin, type ViteDevServer } from "vite";
import react from "@vitejs/plugin-react";
import {
readFileSync,
readdirSync,
existsSync,
writeFileSync,
lstatSync,
realpathSync,
} from "node:fs";
import { isAbsolute, join, relative, resolve } from "node:path";
import type {
StudioApiAdapter,
ResolvedProject,
RenderJobState,
} from "@hyperframes/core/studio-api";
import { createProjectSignature } from "../core/src/studio-api/helpers/projectSignature";
import { createRetryingModuleLoader, ensureProducerDist } from "./vite.producer";
import { readNodeRequestBody } from "./vite.request-body.js";
import { seekThumbnailPreview } from "./vite.thumbnail";
// ── Shared Puppeteer browser ─────────────────────────────────────────────────
let _browser: import("puppeteer-core").Browser | null = null;
let _browserLaunchPromise: Promise<import("puppeteer-core").Browser> | null = null;
async function getSharedBrowser(): Promise<import("puppeteer-core").Browser | null> {
if (_browser?.connected) return _browser;
if (_browserLaunchPromise) return _browserLaunchPromise;
_browserLaunchPromise = (async () => {
try {
const puppeteer = await import("puppeteer-core");
const executablePath = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/usr/bin/google-chrome",
"/usr/bin/chromium-browser",
].find((p) => existsSync(p));
if (!executablePath) return null;
_browser = await puppeteer.default.launch({
headless: true,
executablePath,
// 10s is enough for any healthy local launch; the default 30s lets a
// wedged handshake stall every pending thumbnail before failing.
timeout: 10_000,
args: ["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
});
return _browser;
} catch (err) {
// Without this guard, a launch failure (timeout, missing libs, etc.)
// surfaces as an unhandled rejection through puppeteer's internal RxJS
// chain and crashes the Vite dev server. Log + degrade gracefully —
// the thumbnail route returns a 500 and the rest of the studio keeps
// working.
const msg = err instanceof Error ? err.message : String(err);
console.warn(`[Studio] puppeteer launch failed — thumbnails disabled: ${msg}`);
return null;
} finally {
// Reset on every outcome so a transient failure doesn't poison the
// singleton: subsequent thumbnail requests can retry the launch.
_browserLaunchPromise = null;
}
})();
return _browserLaunchPromise;
}
// In-flight thumbnail dedup
const _thumbnailInflight = new Map<string, Promise<Buffer>>();
const THUMBNAIL_CACHE_VERSION = "v3";
interface ScreenshotClip {
x: number;
y: number;
width: number;
height: number;
}
function isPathWithin(parentDir: string, childPath: string): boolean {
const childRelativePath = relative(resolve(parentDir), resolve(childPath));
return (
childRelativePath === "" ||
(!childRelativePath.startsWith("..") && !isAbsolute(childRelativePath))
);
}
// ── Vite adapter for the shared studio API ───────────────────────────────────
function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAdapter {
// Lazy-load the bundler via Vite's SSR module loader
let _bundler:
| ((dir: string, options?: { runtime?: "inline" | "placeholder" }) => Promise<string>)
| null = null;
let _producerModulePromise: Promise<{
createRenderJob: (config: {
fps: import("@hyperframes/core").Fps;
quality: "draft" | "standard" | "high";
format: string;
outputResolution?: "landscape" | "portrait" | "landscape-4k" | "portrait-4k";
}) => unknown;
executeRenderJob: (
job: unknown,
projectDir: string,
outputPath: string,
onProgress?: (job: { progress: number; currentStage?: string }) => void,
) => Promise<void>;
}> | null = null;
const projectSignatureCache = new Map<string, string>();
server.watcher.on("all", (_event, file) => {
for (const projectDir of projectSignatureCache.keys()) {
if (isPathWithin(projectDir, file)) projectSignatureCache.delete(projectDir);
}
});
const getBundler = async () => {
if (!_bundler) {
try {
const mod = await server.ssrLoadModule("@hyperframes/core/compiler");
_bundler = (dir, options) => mod.bundleToSingleHtml(dir, options);
} catch (err) {
console.warn("[Studio] Failed to load compiler, previews will use raw HTML:", err);
_bundler = null as never;
}
}
return _bundler;
};
const getProducerModule = async () => {
if (!_producerModulePromise) {
_producerModulePromise = createRetryingModuleLoader(async () => {
const { built } = ensureProducerDist({
studioDir: __dirname,
env: process.env,
});
if (built) {
console.warn(
"[Studio] @hyperframes/producer dist missing; building producer package for local renders...",
);
}
const producerPkg = "@hyperframes/producer";
return await import(/* @vite-ignore */ producerPkg);
})();
}
return _producerModulePromise();
};
return {
listProjects() {
if (!existsSync(dataDir)) return [];
const sessionsDir = resolve(dataDir, "../sessions");
const sessionMap = new Map<string, { sessionId: string; title: string }>();
if (existsSync(sessionsDir)) {
for (const file of readdirSync(sessionsDir).filter((f) => f.endsWith(".json"))) {
try {
const raw = JSON.parse(readFileSync(join(sessionsDir, file), "utf-8"));
if (raw.projectId) {
sessionMap.set(raw.projectId, {
sessionId: file.replace(".json", ""),
title: raw.title || "Untitled",
});
}
} catch {
/* skip corrupt */
}
}
}
return readdirSync(dataDir, { withFileTypes: true })
.filter(
(d) =>
(d.isDirectory() || d.isSymbolicLink()) &&
existsSync(join(dataDir, d.name, "index.html")),
)
.map((d) => {
const session = sessionMap.get(d.name);
return {
id: d.name,
dir: join(dataDir, d.name),
title: session?.title ?? d.name,
sessionId: session?.sessionId,
} satisfies ResolvedProject;
})
.sort((a, b) => (a.title ?? "").localeCompare(b.title ?? ""));
},
resolveProject(id: string) {
let projectDir = join(dataDir, id);
if (!existsSync(projectDir)) {
// Try resolving as session ID
const sessionsDir = resolve(dataDir, "../sessions");
const sessionFile = join(sessionsDir, `${id}.json`);
if (existsSync(sessionFile)) {
try {
const session = JSON.parse(readFileSync(sessionFile, "utf-8"));
if (session.projectId) {
projectDir = join(dataDir, session.projectId);
if (existsSync(projectDir)) {
return { id: session.projectId, dir: projectDir, title: session.title };
}
}
} catch {
/* ignore */
}
}
return null;
}
return { id, dir: projectDir };
},
async bundle(dir: string) {
const bundler = await getBundler();
if (!bundler) return null;
// Studio vite preview: bundler emits an empty `src=""` placeholder so we
// can point it at the local /api/runtime.js endpoint. Cached by the browser
// across composition hot-reloads instead of being inlined fresh each time.
let html = await bundler(dir, { runtime: "placeholder" });
html = html.replace(
'data-hyperframes-preview-runtime="1" src=""',
`data-hyperframes-preview-runtime="1" src="${this.runtimeUrl}"`,
);
return html;
},
getProjectSignature(projectDir: string): string {
const cacheKey = resolve(projectDir);
const cached = projectSignatureCache.get(cacheKey);
if (cached) return cached;
const signature = createProjectSignature(cacheKey);
projectSignatureCache.set(cacheKey, signature);
return signature;
},
async lint(html: string, opts?: { filePath?: string }) {
const mod = await server.ssrLoadModule("@hyperframes/core/lint");
return mod.lintHyperframeHtml(html, opts);
},
runtimeUrl: "/api/runtime.js",
rendersDir: () => resolve(dataDir, "../renders"),
startRender(opts): RenderJobState {
const state: RenderJobState = {
id: opts.jobId,
status: "rendering",
progress: 0,
outputPath: opts.outputPath,
};
const startTime = Date.now();
(async () => {
try {
// Help the producer find a browser — it checks PRODUCER_HEADLESS_SHELL_PATH
// but doesn't search system Chrome paths. Reuse the same discovery as thumbnails.
if (!process.env.PRODUCER_HEADLESS_SHELL_PATH) {
const systemChrome = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/usr/bin/google-chrome",
"/usr/bin/chromium-browser",
].find((p) => existsSync(p));
if (systemChrome) process.env.PRODUCER_HEADLESS_SHELL_PATH = systemChrome;
}
const { createRenderJob, executeRenderJob } = await getProducerModule();
const job = createRenderJob({
// opts.fps is already an Fps rational — the studio-api route
// normalized any wire-format `number | string` into the structured
// form before calling this adapter.
fps: opts.fps,
quality: opts.quality as "draft" | "standard" | "high",
format: opts.format,
outputResolution: opts.outputResolution,
});
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);
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 }),
);
} catch (err) {
state.status = "failed";
state.error = err instanceof Error ? err.message : String(err);
try {
const metaPath = opts.outputPath.replace(/\.(mp4|webm|mov)$/, ".meta.json");
writeFileSync(metaPath, JSON.stringify({ status: "failed" }));
} catch {
/* ignore */
}
}
})();
return state;
},
async generateThumbnail(opts) {
const selectorKey = opts.selector
? `_${opts.selector.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 80)}`
: "";
const cacheKey = `${THUMBNAIL_CACHE_VERSION}_${opts.compPath.replace(/\//g, "_")}_${opts.seekTime.toFixed(2)}${selectorKey}.jpg`;
let bufferPromise = _thumbnailInflight.get(cacheKey);
if (!bufferPromise) {
bufferPromise = (async () => {
const browser = await getSharedBrowser();
if (!browser) return null;
const page = await browser.newPage();
await page.setViewport({
width: opts.width,
height: opts.height,
deviceScaleFactor: opts.format === "png" ? 1 : 0.5,
});
await page.goto(opts.previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 });
await page.evaluate(() => {
document.documentElement.style.background = "#000";
document.body.style.background = "#000";
document.body.style.margin = "0";
document.body.style.overflow = "hidden";
});
await page
.waitForFunction(
`!!(window.__timelines && Object.keys(window.__timelines).length > 0)`,
{ timeout: 5000 },
)
.catch(() => {});
await seekThumbnailPreview(page, opts.seekTime);
await page.evaluate("document.fonts?.ready");
await new Promise((r) => setTimeout(r, 200));
let clip: ScreenshotClip | undefined;
if (opts.selector) {
clip = await page.evaluate((selector: string) => {
const el = document.querySelector(selector);
if (!(el instanceof HTMLElement)) return undefined;
const rect = el.getBoundingClientRect();
if (rect.width < 4 || rect.height < 4) return undefined;
const pad = 8;
const x = Math.max(0, rect.left - pad);
const y = Math.max(0, rect.top - pad);
const maxWidth = window.innerWidth - x;
const maxHeight = window.innerHeight - y;
return {
x,
y,
width: Math.max(1, Math.min(rect.width + pad * 2, maxWidth)),
height: Math.max(1, Math.min(rect.height + pad * 2, maxHeight)),
};
}, opts.selector);
}
const buf = await page.screenshot(
opts.format === "png"
? {
type: "png",
...(clip ? { clip } : {}),
}
: {
type: "jpeg",
quality: 75,
...(clip ? { clip } : {}),
},
);
await page.close();
return buf as Buffer;
})();
_thumbnailInflight.set(cacheKey, bufferPromise);
bufferPromise.finally(() => _thumbnailInflight.delete(cacheKey));
}
return bufferPromise;
},
async resolveSession(sessionId: string) {
const sessionsDir = resolve(dataDir, "../sessions");
const sessionFile = join(sessionsDir, `${sessionId}.json`);
if (!existsSync(sessionFile)) return null;
try {
const raw = JSON.parse(readFileSync(sessionFile, "utf-8"));
if (raw.projectId) return { projectId: raw.projectId, title: raw.title };
} catch {
/* ignore */
}
return null;
},
};
}
async function loadRuntimeSourceForDev(server: ViteDevServer): Promise<string | null> {
try {
const mod = await server.ssrLoadModule(
resolve(__dirname, "../core/src/inline-scripts/hyperframe.ts"),
);
if (typeof mod.loadHyperframeRuntimeSource === "function") {
return mod.loadHyperframeRuntimeSource();
}
} catch (err) {
console.warn("[Studio] Failed to load runtime source from core:", err);
}
return null;
}
// ── Bridge Hono fetch → Node http response ───────────────────────────────────
async function bridgeHonoResponse(
honoResponse: Response,
res: import("node:http").ServerResponse,
): Promise<void> {
const headers: Record<string, string> = {};
honoResponse.headers.forEach((v, k) => {
headers[k] = v;
});
res.writeHead(honoResponse.status, headers);
if (!honoResponse.body) {
res.end();
return;
}
// Stream the response body (important for SSE)
const reader = honoResponse.body.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
res.write(value);
}
} catch {
/* client disconnected */
}
res.end();
}
// ── Vite plugin ──────────────────────────────────────────────────────────────
function devProjectApi(): Plugin {
const dataDir = resolve(__dirname, "data/projects");
return {
name: "studio-dev-api",
configureServer(server): void {
// Load the shared module lazily via SSR (resolves hono + TypeScript)
let _api: { fetch: (req: Request) => Promise<Response> } | null = null;
const getApi = async () => {
if (!_api) {
const mod = await server.ssrLoadModule("@hyperframes/core/studio-api");
const adapter = createViteAdapter(dataDir, server);
_api = mod.createStudioApi(adapter);
}
return _api;
};
// In dev, prefer the runtime built from source over a checked-in dist
// artifact. Otherwise Studio can silently serve a stale runtime bundle
// after source edits in packages/core, which makes browser behavior lag
// behind the code under test until someone manually rebuilds core/dist.
const runtimePath = resolve(__dirname, "../core/dist/hyperframe.runtime.iife.js");
server.middlewares.use((req, res, next) => {
if (req.url !== "/api/runtime.js") return next();
const serve = async () => {
let runtimeSource = await loadRuntimeSourceForDev(server);
if (!runtimeSource && existsSync(runtimePath)) {
runtimeSource = readFileSync(runtimePath, "utf-8");
}
if (!runtimeSource) {
res.writeHead(404);
res.end("runtime not available — build packages/core or load runtime source");
return;
}
res.writeHead(200, {
"Content-Type": "text/javascript",
"Cache-Control": "no-store",
});
res.end(runtimeSource);
};
void serve().catch((err) => {
console.error("[Studio runtime] Failed to serve runtime", err);
if (!res.headersSent) {
res.writeHead(500);
res.end("failed to serve runtime");
}
});
});
server.middlewares.use(async (req, res, next) => {
if (!req.url?.startsWith("/api/")) return next();
try {
const api = await getApi();
// Build a Fetch Request from the Node IncomingMessage
const url = new URL(req.url, `http://${req.headers.host}`);
// Strip /api prefix — shared module routes are relative
url.pathname = url.pathname.slice(4);
// Read body for non-GET/HEAD
let body: Buffer | undefined;
if (req.method !== "GET" && req.method !== "HEAD") {
const bytes = await readNodeRequestBody(req);
body = bytes.byteLength > 0 ? bytes : undefined;
}
const headers: Record<string, string> = {};
for (const [key, value] of Object.entries(req.headers)) {
if (value != null) headers[key] = Array.isArray(value) ? value.join(", ") : value;
}
const fetchReq = new Request(url.toString(), {
method: req.method,
headers,
body,
});
const response = await api.fetch(fetchReq);
await bridgeHonoResponse(response, res);
} catch (err) {
console.error("[Studio API] Error:", err);
if (!res.headersSent) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Internal server error" }));
}
}
});
// Watch project directories for file changes → HMR
const realProjectPaths: string[] = [];
try {
for (const entry of readdirSync(dataDir, { withFileTypes: true })) {
const full = join(dataDir, entry.name);
try {
const real = lstatSync(full).isSymbolicLink() ? realpathSync(full) : full;
realProjectPaths.push(real);
server.watcher.add(real);
} catch {
/* skip broken symlinks */
}
}
} catch {
/* dataDir doesn't exist yet */
}
server.watcher.on("change", (filePath: string) => {
const isProjectFile = realProjectPaths.some((p) => filePath.startsWith(p));
if (
isProjectFile &&
(filePath.endsWith(".html") || filePath.endsWith(".css") || filePath.endsWith(".js"))
) {
console.log(`[Studio] File changed: ${filePath}`);
server.ws.send({ type: "custom", event: "hf:file-change", data: {} });
}
});
},
};
}
export default defineConfig({
plugins: [react(), devProjectApi()],
resolve: {
alias: {
"@hyperframes/player": resolve(__dirname, "../player/src/hyperframes-player.ts"),
},
},
build: {
outDir: "dist",
emptyOutDir: true,
},
server: {
port: 5190,
},
});