refactor(core): extract shared studio API module (#113)

## Summary
Extracts all studio API routes into a shared Hono-based module at `@hyperframes/core/studio-api`.

### Architecture
- **`StudioApiAdapter` interface** — consumers inject host-specific behavior (project resolution, bundling, rendering, thumbnails)
- **Shared route modules**: projects, files, preview, lint, render, thumbnail
- **Shared helpers**: `isSafePath`, `walkDir`, `getMimeType`, `buildSubCompositionHtml`

### What this PR does
- Creates the shared module with all API routes extracted from both `vite.config.ts` and `studioServer.ts`
- Both consumers will be refactored in follow-up commits to mount this module with their own adapter

### What stays in each consumer
- **Vite**: SSR module loading, Puppeteer thumbnails, file watcher + HMR, producer HTTP proxy, multi-project scanning
- **CLI**: in-process `executeRenderJob`, local runtime serving, browser management, SPA static file serving

### Follow-up needed
- [ ] Refactor `packages/studio/vite.config.ts` to use `createStudioApi(adapter)` via `@hono/node-server`'s `getRequestListener`
- [ ] Refactor `packages/cli/src/server/studioServer.ts` to use `createStudioApi(adapter)`
- [ ] Add `./studio-api` export path to `packages/core/package.json`
- [ ] Add `hono` as peer dependency of `@hyperframes/core`

## Test plan
- [ ] Verify shared module compiles without type errors
- [ ] After consumer refactoring: all studio features work identically via both vite dev and CLI embedded servers

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Miguel Ángel
2026-03-28 22:28:12 +01:00
committed by GitHub
parent fecd4c8485
commit bd175b64a6
15 changed files with 848 additions and 471 deletions
+6
View File
@@ -71,6 +71,12 @@
"cheerio": "^1.2.0",
"esbuild": "^0.25.12",
},
"peerDependencies": {
"hono": "^4.0.0",
},
"optionalPeers": [
"hono",
],
},
"packages/engine": {
"name": "@hyperframes/engine",
+132 -469
View File
@@ -1,35 +1,31 @@
/**
* Embedded studio server for `hyperframes dev` outside the monorepo.
*
* Serves the pre-built studio SPA and implements the project API that the
* studio expects. Ports the API logic from packages/studio/vite.config.ts.
* Uses the shared studio API module from @hyperframes/core/studio-api,
* providing a CLI-specific adapter for single-project, in-process rendering.
*/
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import {
existsSync,
readFileSync,
readdirSync,
statSync,
writeFileSync,
mkdirSync,
unlinkSync,
} from "node:fs";
import { resolve, join, sep, basename, dirname, extname } from "node:path";
import { existsSync, readFileSync, writeFileSync, statSync } from "node:fs";
import { resolve, join, basename } from "node:path";
import { createProjectWatcher, type ProjectWatcher } from "./fileWatcher.js";
import {
createStudioApi,
getMimeType,
type StudioApiAdapter,
type ResolvedProject,
type RenderJobState,
} from "@hyperframes/core/studio-api";
// ── Path resolution ─────────────────────────────────────────────────────────
function resolveDistDir(): string {
// __dirname is injected by tsup banner — points to dist/ in the built CLI.
// In dev mode (tsx), it points to src/server/.
const builtPath = resolve(__dirname, "studio");
if (existsSync(resolve(builtPath, "index.html"))) return builtPath;
// Fallback for dev mode: built studio is at packages/studio/dist
const devPath = resolve(__dirname, "..", "..", "..", "studio", "dist");
if (existsSync(resolve(devPath, "index.html"))) return devPath;
return builtPath; // let it fail with a clear 404
return builtPath;
}
function resolveRuntimePath(): string {
@@ -48,134 +44,6 @@ function resolveRuntimePath(): string {
return builtPath;
}
// ── Safety ──────────────────────────────────────────────────────────────────
function isSafePath(base: string, resolved: string): boolean {
const norm = resolve(base) + sep;
return resolved.startsWith(norm) || resolved === resolve(base);
}
// ── MIME types ──────────────────────────────────────────────────────────────
const MIME_TYPES: Record<string, string> = {
".html": "text/html",
".js": "text/javascript",
".css": "text/css",
".json": "application/json",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".mp4": "video/mp4",
".webm": "video/webm",
".mp3": "audio/mpeg",
".wav": "audio/wav",
".m4a": "audio/mp4",
".ogg": "audio/ogg",
".woff2": "font/woff2",
".woff": "font/woff",
".ttf": "font/ttf",
};
function getMimeType(filePath: string): string {
const ext = extname(filePath).toLowerCase();
return MIME_TYPES[ext] ?? "application/octet-stream";
}
// ── File helpers ────────────────────────────────────────────────────────────
function walkDir(dir: string, prefix: string = ""): string[] {
const files: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
files.push(...walkDir(join(dir, entry.name), rel));
} else {
files.push(rel);
}
}
return files;
}
function serveStaticFile(filePath: string): Response | null {
if (!existsSync(filePath) || !statSync(filePath).isFile()) return null;
const mime = getMimeType(filePath);
const content = readFileSync(filePath);
return new Response(content, {
headers: { "Content-Type": mime, "Cache-Control": "no-store" },
});
}
// ── Sub-composition builder ─────────────────────────────────────────────────
// Ports vite.config.ts lines 216-301
function buildSubCompositionHtml(
projectDir: string,
compPath: string,
runtimeUrl: string,
): string | null {
const compFile = resolve(projectDir, compPath);
if (!isSafePath(projectDir, compFile) || !existsSync(compFile) || !statSync(compFile).isFile()) {
return null;
}
let rawComp = readFileSync(compFile, "utf-8");
// Extract content from <template> if present
const templateMatch = rawComp.match(/<template>([\s\S]*)<\/template>/i);
let content = (templateMatch ? templateMatch[1] : rawComp) ?? rawComp;
// Inline nested data-composition-src references
content = content.replace(
/(<[^>]*?)(data-composition-src=["']([^"']+)["'])([^>]*>)/g,
(_match, before, srcAttr, src, after) => {
const nestedFile = join(projectDir, src);
if (!existsSync(nestedFile)) return before + srcAttr + after;
const nestedRaw = readFileSync(nestedFile, "utf-8");
const nestedTemplate = nestedRaw.match(/<template>([\s\S]*)<\/template>/i);
const nestedContent = (nestedTemplate ? nestedTemplate[1] : nestedRaw) ?? nestedRaw;
const styles: string[] = [];
const scripts: string[] = [];
let body = nestedContent
.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (_, css) => {
styles.push(css);
return "";
})
.replace(/<script[^>]*>([\s\S]*?)<\/script>/gi, (_, js) => {
scripts.push(js);
return "";
});
const innerRootMatch = body.match(
/<([a-z][a-z0-9]*)\b[^>]*data-composition-id[^>]*>([\s\S]*)<\/\1>/i,
);
const innerHTML = innerRootMatch ? innerRootMatch[2] : body;
return (
before +
srcAttr +
after.replace(/>$/, ">") +
innerHTML +
(styles.length ? `<style>${styles.join("\n")}</style>` : "") +
(scripts.length
? `<script>${scripts.map((s) => `(function(){try{${s}}catch(e){}})();`).join("\n")}</script>`
: "")
);
},
);
return `<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script data-hyperframes-preview-runtime="1" src="${runtimeUrl}"></script>
</head>
<body>
${content}
</body>
</html>`;
}
// ── Server factory ──────────────────────────────────────────────────────────
export interface StudioServerOptions {
@@ -194,9 +62,101 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
const runtimePath = resolveRuntimePath();
const watcher = createProjectWatcher(projectDir);
// ── CLI adapter for the shared studio API ──────────────────────────────
const project: ResolvedProject = { id: projectId, dir: projectDir, title: projectId };
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");
let html = await bundleToSingleHtml(dir);
// Fix empty runtime src from bundler — point to the local runtime endpoint
html = html.replace(
'data-hyperframes-preview-runtime="1" src=""',
'data-hyperframes-preview-runtime="1" src="/api/runtime.js"',
);
return html;
} catch {
return null;
}
},
async lint(html: string, opts?: { filePath?: string }) {
const { lintHyperframeHtml } = await import("@hyperframes/core/lint");
return lintHyperframeHtml(html, opts);
},
runtimeUrl: "/api/runtime.js",
rendersDir: () => join(projectDir, "renders"),
startRender(opts): RenderJobState {
const state: RenderJobState = {
id: opts.jobId,
status: "rendering",
progress: 0,
outputPath: opts.outputPath,
};
// Run render asynchronously, mutating the state object
(async () => {
try {
const { createRenderJob, executeRenderJob } = await import("@hyperframes/producer");
const { ensureBrowser } = await import("../browser/manager.js");
try {
const browser = await ensureBrowser();
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 job = createRenderJob({
fps: opts.fps as 24 | 30 | 60,
quality: opts.quality as "draft" | "standard" | "high",
format: opts.format,
});
const startTime = Date.now();
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)$/, ".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)$/, ".meta.json");
writeFileSync(metaPath, JSON.stringify({ status: "failed" }));
} catch {
/* ignore */
}
}
})();
return state;
},
};
// ── Build the Hono app ─────────────────────────────────────────────────
const app = new Hono();
// ── API: runtime.js ───────────────────────────────────────────────────
// CLI-specific routes (before shared API)
app.get("/api/runtime.js", (c) => {
if (!existsSync(runtimePath)) return c.text("runtime not built", 404);
return c.body(readFileSync(runtimePath, "utf-8"), 200, {
@@ -205,352 +165,55 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
});
});
// ── API: SSE events ───────────────────────────────────────────────────
app.get("/api/events", (c) => {
return streamSSE(c, async (stream) => {
const listener = () => {
stream.writeSSE({ event: "file-change", data: "{}" }).catch(() => {});
};
watcher.addListener(listener);
// Keep connection alive until client disconnects
while (true) {
await stream.sleep(30000);
}
});
});
// ── API: project listing ──────────────────────────────────────────────
app.get("/api/projects", (c) => {
return c.json({ projects: [{ id: projectId, title: projectId }] });
});
// ── API: project file tree ────────────────────────────────────────────
app.get("/api/projects/:id", (c) => {
const id = c.req.param("id");
if (id !== projectId) return c.json({ error: "not found" }, 404);
const files = walkDir(projectDir);
return c.json({ id: projectId, files });
});
// ── API: lint ───────────────────────────────────────────────────────
app.get("/api/projects/:id/lint", async (c) => {
const id = c.req.param("id");
if (id !== projectId) return c.json({ error: "not found" }, 404);
try {
const { lintHyperframeHtml } = await import("@hyperframes/core/lint");
const htmlFiles = walkDir(projectDir).filter((f: string) => f.endsWith(".html"));
const allFindings: Array<{
severity: string;
message: string;
file?: string;
fixHint?: string;
}> = [];
for (const file of htmlFiles) {
const content = readFileSync(join(projectDir, file), "utf-8");
const result = lintHyperframeHtml(content, { filePath: file });
if (result?.findings) {
for (const f of result.findings) {
allFindings.push({ ...f, file });
}
}
}
return c.json({ findings: allFindings });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return c.json({ error: `Lint failed: ${msg}` }, 500);
}
});
// ── API: preview — bundled composition ────────────────────────────────
app.get("/api/projects/:id/preview", async (c) => {
const id = c.req.param("id");
if (id !== projectId) return c.json({ error: "not found" }, 404);
let bundled: string;
try {
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
bundled = await bundleToSingleHtml(projectDir);
} catch {
// Fallback to raw HTML
const file = join(projectDir, "index.html");
if (!existsSync(file)) return c.text("not found", 404);
bundled = readFileSync(file, "utf-8");
}
// Inject <base> so relative asset paths resolve through /preview/ route
const baseTag = `<base href="/api/projects/${projectId}/preview/">`;
if (bundled.includes("<head>")) {
bundled = bundled.replace("<head>", `<head>${baseTag}`);
} else {
bundled = baseTag + bundled;
}
// Fix empty runtime src if present
bundled = bundled.replace(
'data-hyperframes-preview-runtime="1" src=""',
'data-hyperframes-preview-runtime="1" src="/api/runtime.js"',
);
return c.html(bundled);
});
// ── API: sub-composition preview ──────────────────────────────────────
app.get("/api/projects/:id/preview/comp/*", (c) => {
const id = c.req.param("id");
if (id !== projectId) return c.json({ error: "not found" }, 404);
const compPath = c.req.path.replace(`/api/projects/${id}/preview/comp/`, "");
const html = buildSubCompositionHtml(
projectDir,
decodeURIComponent(compPath),
"/api/runtime.js",
);
if (!html) return c.text("not found", 404);
return c.html(html);
});
// ── API: preview static assets ────────────────────────────────────────
app.get("/api/projects/:id/preview/*", (c) => {
const id = c.req.param("id");
if (id !== projectId) return c.json({ error: "not found" }, 404);
const subPath = decodeURIComponent(
c.req.path.replace(`/api/projects/${id}/preview/`, "").split("?")[0] ?? "",
);
const file = resolve(projectDir, subPath);
if (!isSafePath(projectDir, file) || !existsSync(file) || !statSync(file).isFile()) {
return c.text("not found", 404);
}
const mime = getMimeType(file);
const content = readFileSync(file);
return new Response(content, {
headers: { "Content-Type": mime, "Cache-Control": "no-store" },
// 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);
});
// ── API: file read ────────────────────────────────────────────────────
app.get("/api/projects/:id/files/*", (c) => {
const id = c.req.param("id");
if (id !== projectId) return c.json({ error: "not found" }, 404);
const filePath = decodeURIComponent(c.req.path.replace(`/api/projects/${id}/files/`, ""));
const file = resolve(projectDir, filePath);
if (!isSafePath(projectDir, file) || !existsSync(file)) {
return c.text("not found", 404);
}
const content = readFileSync(file, "utf-8");
return c.json({ filename: filePath, content });
});
// ── API: file write ───────────────────────────────────────────────────
app.put("/api/projects/:id/files/*", async (c) => {
const id = c.req.param("id");
if (id !== projectId) return c.json({ error: "not found" }, 404);
const filePath = decodeURIComponent(c.req.path.replace(`/api/projects/${id}/files/`, ""));
const file = resolve(projectDir, filePath);
if (!isSafePath(projectDir, file)) {
return c.json({ error: "forbidden" }, 403);
}
// Ensure parent directory exists
const dir = dirname(file);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
const body = await c.req.text();
writeFileSync(file, body, "utf-8");
return c.json({ ok: true });
});
// ── API: stub endpoints ───────────────────────────────────────────────
app.get("/api/resolve-session/:id", (c) => c.json({ error: "not available" }, 404));
// ── API: render ─────────────────────────────────────────────────────
// In-memory job store for active renders
const renderJobs = new Map<
string,
{ status: string; progress: number; stage?: string; error?: string; outputPath?: string }
>();
app.post("/api/projects/:id/render", async (c) => {
const id = c.req.param("id");
if (id !== projectId) return c.json({ error: "not found" }, 404);
const body = (await c.req.json().catch(() => ({}))) as {
fps?: number;
quality?: string;
format?: string;
};
const format = body.format === "webm" ? "webm" : "mp4";
const fps: 24 | 30 | 60 = body.fps === 24 || body.fps === 60 ? body.fps : 30;
const quality = ["draft", "standard", "high"].includes(body.quality ?? "")
? (body.quality as "draft" | "standard" | "high")
: "standard";
const now = new Date();
const datePart = now.toISOString().slice(0, 10);
const timePart = now.toTimeString().slice(0, 8).replace(/:/g, "-");
const jobId = `${projectId}_${datePart}_${timePart}`;
const outputDir = join(projectDir, "renders");
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
const ext = format === "webm" ? ".webm" : ".mp4";
const outputPath = join(outputDir, `${jobId}${ext}`);
renderJobs.set(jobId, { status: "rendering", progress: 0, outputPath });
// Run render asynchronously
(async () => {
try {
const { createRenderJob, executeRenderJob } = await import("@hyperframes/producer");
const { ensureBrowser } = await import("../browser/manager.js");
// Ensure browser is available and pass path to producer
try {
const browser = await ensureBrowser();
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 job = createRenderJob({ fps, quality, format });
const startTime = Date.now();
const onProgress = (j: { progress: number; currentStage?: string }) => {
const entry = renderJobs.get(jobId);
if (entry) {
entry.progress = j.progress;
if (j.currentStage) entry.stage = j.currentStage;
}
};
await executeRenderJob(job, projectDir, outputPath, onProgress);
const entry = renderJobs.get(jobId);
if (entry) {
entry.status = "complete";
entry.progress = 100;
}
const metaPath = outputPath.replace(/\.(mp4|webm)$/, ".meta.json");
writeFileSync(
metaPath,
JSON.stringify({ status: "complete", durationMs: Date.now() - startTime }),
);
} catch (err) {
const entry = renderJobs.get(jobId);
if (entry) {
entry.status = "failed";
entry.error = err instanceof Error ? err.message : String(err);
}
try {
const metaPath = outputPath.replace(/\.(mp4|webm)$/, ".meta.json");
writeFileSync(metaPath, JSON.stringify({ status: "failed" }));
} catch {
/* ignore */
}
}
})();
return c.json({ jobId });
});
app.get("/api/render/:jobId/progress", (c) => {
const { jobId } = c.req.param();
const job = renderJobs.get(jobId);
if (!job) return c.json({ error: "not found" }, 404);
return streamSSE(c, async (stream) => {
while (true) {
const current = renderJobs.get(jobId);
if (!current) break;
await stream.writeSSE({
event: "progress",
data: JSON.stringify({
progress: current.progress,
status: current.status,
stage: current.stage,
error: current.error,
}),
});
if (current.status === "complete" || current.status === "failed") break;
await stream.sleep(500);
}
});
});
app.get("/api/render/:jobId/download", (c) => {
const { jobId } = c.req.param();
const job = renderJobs.get(jobId);
if (!job?.outputPath || !existsSync(job.outputPath)) {
return c.json({ error: "not found" }, 404);
}
const isWebm = job.outputPath.endsWith(".webm");
const contentType = isWebm ? "video/webm" : "video/mp4";
const filename = job.outputPath.split("/").pop() ?? `${projectId}.mp4`;
const content = readFileSync(job.outputPath);
return new Response(content, {
headers: {
"Content-Type": contentType,
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
});
// ── API: renders listing ──────────────────────────────────────────────
app.get("/api/projects/:id/renders", (c) => {
const id = c.req.param("id");
if (id !== projectId) return c.json({ error: "not found" }, 404);
const rendersDir = join(projectDir, "renders");
if (!existsSync(rendersDir)) return c.json({ renders: [] });
const files = readdirSync(rendersDir)
.filter((f: string) => f.endsWith(".mp4") || f.endsWith(".webm"))
.map((f: string) => {
const fp = join(rendersDir, f);
const stat = statSync(fp);
const rid = f.replace(/\.(mp4|webm)$/, "");
const metaPath = join(rendersDir, `${rid}.meta.json`);
let status: "complete" | "failed" = "complete";
let durationMs: number | undefined;
if (existsSync(metaPath)) {
try {
const meta = JSON.parse(readFileSync(metaPath, "utf-8"));
if (meta.status === "failed") status = "failed";
if (meta.durationMs) durationMs = meta.durationMs;
} catch {
/* ignore */
}
}
return {
id: rid,
filename: f,
size: stat.size,
createdAt: stat.mtimeMs,
status,
durationMs,
};
})
.sort((a: { createdAt: number }, b: { createdAt: number }) => b.createdAt - a.createdAt);
return c.json({ renders: files });
});
// ── API: delete render ───────────────────────────────────────────────
app.delete("/api/render/:jobId", (c) => {
const { jobId } = c.req.param();
const rendersDir = join(projectDir, "renders");
for (const ext of [".mp4", ".webm", ".meta.json"]) {
const fp = join(rendersDir, `${jobId}${ext}`);
if (existsSync(fp)) unlinkSync(fp);
}
renderJobs.delete(jobId);
return c.json({ deleted: true });
});
// ── Studio SPA static files ───────────────────────────────────────────
// Studio SPA static files
app.get("/assets/*", (c) => {
const filePath = resolve(studioDir, c.req.path.slice(1)); // strip leading /
const resp = serveStaticFile(filePath);
return resp ?? c.text("not found", 404);
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("/icons/*", (c) => {
const filePath = resolve(studioDir, c.req.path.slice(1));
const resp = serveStaticFile(filePath);
return resp ?? c.text("not found", 404);
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" },
});
});
// ── SPA fallback — serve index.html for all unmatched routes ──────────
// SPA fallback
app.get("*", (c) => {
const indexPath = resolve(studioDir, "index.html");
if (!existsSync(indexPath)) {
+18 -2
View File
@@ -28,7 +28,11 @@
"import": "./src/compiler/index.ts",
"types": "./src/compiler/index.ts"
},
"./runtime": "./dist/hyperframe.runtime.iife.js"
"./runtime": "./dist/hyperframe.runtime.iife.js",
"./studio-api": {
"import": "./src/studio-api/index.ts",
"types": "./src/studio-api/index.ts"
}
},
"publishConfig": {
"access": "public",
@@ -45,7 +49,11 @@
"import": "./dist/compiler/index.js",
"types": "./dist/compiler/index.d.ts"
},
"./runtime": "./dist/hyperframe.runtime.iife.js"
"./runtime": "./dist/hyperframe.runtime.iife.js",
"./studio-api": {
"import": "./dist/studio-api/index.js",
"types": "./dist/studio-api/index.d.ts"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
@@ -81,6 +89,14 @@
"typescript": "^5.0.0",
"vitest": "^3.2.4"
},
"peerDependencies": {
"hono": "^4.0.0"
},
"peerDependenciesMeta": {
"hono": {
"optional": true
}
},
"optionalDependencies": {
"cheerio": "^1.2.0",
"esbuild": "^0.25.12"
@@ -0,0 +1,27 @@
import { Hono } from "hono";
import type { StudioApiAdapter } from "./types.js";
import { registerProjectRoutes } from "./routes/projects.js";
import { registerFileRoutes } from "./routes/files.js";
import { registerPreviewRoutes } from "./routes/preview.js";
import { registerLintRoutes } from "./routes/lint.js";
import { registerRenderRoutes } from "./routes/render.js";
import { registerThumbnailRoutes } from "./routes/thumbnail.js";
/**
* Create a Hono sub-app with all studio API routes.
*
* Both the vite dev server and CLI embedded server mount this app
* under /api, each providing their own adapter for host-specific behavior.
*/
export function createStudioApi(adapter: StudioApiAdapter): Hono {
const api = new Hono();
registerProjectRoutes(api, adapter);
registerFileRoutes(api, adapter);
registerPreviewRoutes(api, adapter);
registerLintRoutes(api, adapter);
registerRenderRoutes(api, adapter);
registerThumbnailRoutes(api, adapter);
return api;
}
@@ -0,0 +1,31 @@
export const MIME_TYPES: Record<string, string> = {
".html": "text/html",
".css": "text/css",
".js": "text/javascript",
".mjs": "text/javascript",
".json": "application/json",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".ico": "image/x-icon",
".mp4": "video/mp4",
".webm": "video/webm",
".mp3": "audio/mpeg",
".wav": "audio/wav",
".ogg": "audio/ogg",
".m4a": "audio/mp4",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".otf": "font/otf",
".txt": "text/plain",
".md": "text/markdown",
};
export function getMimeType(path: string): string {
const ext = path.slice(path.lastIndexOf(".")).toLowerCase();
return MIME_TYPES[ext] || "application/octet-stream";
}
@@ -0,0 +1,25 @@
import { resolve, sep, join } from "node:path";
import { readdirSync } from "node:fs";
/** Reject paths that escape the project directory. */
export function isSafePath(base: string, resolved: string): boolean {
const norm = resolve(base) + sep;
return resolved.startsWith(norm) || resolved === resolve(base);
}
const IGNORE_DIRS = new Set([".thumbnails", "node_modules", ".git"]);
/** Recursively walk a directory and return relative file paths. */
export function walkDir(dir: string, prefix = ""): string[] {
const files: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (IGNORE_DIRS.has(entry.name)) continue;
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
files.push(...walkDir(join(dir, entry.name), rel));
} else {
files.push(rel);
}
}
return files;
}
@@ -0,0 +1,64 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
/**
* Build a standalone HTML page for a sub-composition.
*
* Uses the project's own index.html `<head>` so all dependencies (GSAP, fonts,
* Lottie, reset styles, runtime) are preserved — instead of building a minimal
* page from scratch that would miss important scripts/styles.
*/
export function buildSubCompositionHtml(
projectDir: string,
compPath: string,
runtimeUrl: string,
baseHref?: string,
): string | null {
const compFile = join(projectDir, compPath);
if (!existsSync(compFile)) return null;
const rawComp = readFileSync(compFile, "utf-8");
// Extract content from <template> wrapper (compositions are always templates)
const templateMatch = rawComp.match(/<template[^>]*>([\s\S]*)<\/template>/i);
const content = templateMatch?.[1] ?? rawComp;
// Use the project's index.html <head> to preserve all dependencies
const indexPath = join(projectDir, "index.html");
let headContent = "";
if (existsSync(indexPath)) {
const indexHtml = readFileSync(indexPath, "utf-8");
const headMatch = indexHtml.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
headContent = headMatch?.[1] ?? "";
}
// Inject <base> for relative asset resolution (before other tags)
if (baseHref && !headContent.includes("<base")) {
headContent = `<base href="${baseHref}">\n${headContent}`;
}
// Ensure runtime is present (might differ from the one in index.html)
if (
!headContent.includes("hyperframe.runtime") &&
!headContent.includes("hyperframes-preview-runtime")
) {
headContent += `\n<script data-hyperframes-preview-runtime="1" src="${runtimeUrl}"></script>`;
}
// Fallback: if no index.html head was found, add minimal deps
if (!headContent.includes("gsap")) {
headContent += `\n<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`;
}
return `<!DOCTYPE html>
<html>
<head>
${headContent}
</head>
<body>
<script>window.__timelines=window.__timelines||{};</script>
${content}
</body>
</html>`;
}
+5
View File
@@ -0,0 +1,5 @@
export { createStudioApi } from "./createStudioApi.js";
export type { StudioApiAdapter, ResolvedProject, RenderJobState, LintResult } 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,36 @@
import type { Hono } from "hono";
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { resolve, dirname } from "node:path";
import type { StudioApiAdapter } from "../types.js";
import { isSafePath } from "../helpers/safePath.js";
export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
// Read file content
api.get("/projects/:id/files/*", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const filePath = decodeURIComponent(c.req.path.replace(`/projects/${project.id}/files/`, ""));
const file = resolve(project.dir, filePath);
if (!isSafePath(project.dir, file) || !existsSync(file)) {
return c.text("not found", 404);
}
const content = readFileSync(file, "utf-8");
return c.json({ filename: filePath, content });
});
// Write file content
api.put("/projects/:id/files/*", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const filePath = decodeURIComponent(c.req.path.replace(`/projects/${project.id}/files/`, ""));
const file = resolve(project.dir, filePath);
if (!isSafePath(project.dir, file)) {
return c.json({ error: "forbidden" }, 403);
}
const dir = dirname(file);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
const body = await c.req.text();
writeFileSync(file, body, "utf-8");
return c.json({ ok: true });
});
}
@@ -0,0 +1,34 @@
import type { Hono } from "hono";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { StudioApiAdapter } from "../types.js";
import { walkDir } from "../helpers/safePath.js";
export function registerLintRoutes(api: Hono, adapter: StudioApiAdapter): void {
api.get("/projects/:id/lint", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
try {
const htmlFiles = walkDir(project.dir).filter((f) => f.endsWith(".html"));
const allFindings: Array<{
severity: string;
message: string;
file?: string;
fixHint?: string;
}> = [];
for (const file of htmlFiles) {
const content = readFileSync(join(project.dir, file), "utf-8");
const result = await adapter.lint(content, { filePath: file });
if (result?.findings) {
for (const f of result.findings) {
allFindings.push({ ...f, file });
}
}
}
return c.json({ findings: allFindings });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return c.json({ error: `Lint failed: ${msg}` }, 500);
}
});
}
@@ -0,0 +1,87 @@
import type { Hono } from "hono";
import { existsSync, readFileSync, statSync } from "node:fs";
import { resolve } from "node:path";
import type { StudioApiAdapter } from "../types.js";
import { isSafePath } from "../helpers/safePath.js";
import { getMimeType } from "../helpers/mime.js";
import { buildSubCompositionHtml } from "../helpers/subComposition.js";
export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): void {
// Bundled composition preview
api.get("/projects/:id/preview", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
try {
let bundled = await adapter.bundle(project.dir);
if (!bundled) {
const indexPath = resolve(project.dir, "index.html");
if (!existsSync(indexPath)) return c.text("not found", 404);
bundled = readFileSync(indexPath, "utf-8");
}
// Inject runtime if not already present (check URL pattern and bundler attribute)
if (
!bundled.includes("hyperframe.runtime") &&
!bundled.includes("hyperframes-preview-runtime")
) {
const runtimeTag = `<script src="${adapter.runtimeUrl}"></script>`;
bundled = bundled.includes("</body>")
? bundled.replace("</body>", `${runtimeTag}\n</body>`)
: bundled + `\n${runtimeTag}`;
}
// Inject <base> for relative asset resolution
const baseHref = `/api/projects/${project.id}/preview/`;
if (!bundled.includes("<base")) {
bundled = bundled.replace(/<head>/i, `<head><base href="${baseHref}">`);
}
return c.html(bundled);
} catch {
const file = resolve(project.dir, "index.html");
if (existsSync(file)) return c.html(readFileSync(file, "utf-8"));
return c.text("not found", 404);
}
});
// Sub-composition preview
api.get("/projects/:id/preview/comp/*", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const compPath = decodeURIComponent(
c.req.path.replace(`/projects/${project.id}/preview/comp/`, "").split("?")[0] ?? "",
);
const compFile = resolve(project.dir, compPath);
if (
!isSafePath(project.dir, compFile) ||
!existsSync(compFile) ||
!statSync(compFile).isFile()
) {
return c.text("not found", 404);
}
const baseHref = `/api/projects/${project.id}/preview/`;
const html = buildSubCompositionHtml(project.dir, compPath, adapter.runtimeUrl, baseHref);
if (!html) return c.text("not found", 404);
return c.html(html);
});
// Static asset serving
api.get("/projects/:id/preview/*", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const subPath = decodeURIComponent(
c.req.path.replace(`/projects/${project.id}/preview/`, "").split("?")[0] ?? "",
);
const file = resolve(project.dir, subPath);
if (!isSafePath(project.dir, file) || !existsSync(file) || !statSync(file).isFile()) {
return c.text("not found", 404);
}
const contentType = getMimeType(subPath);
const isText = /\.(html|css|js|json|svg|txt|md)$/i.test(subPath);
const content = readFileSync(file, isText ? "utf-8" : undefined);
return new Response(content, {
headers: { "Content-Type": contentType },
});
});
}
@@ -0,0 +1,30 @@
import type { Hono } from "hono";
import type { StudioApiAdapter } from "../types.js";
import { walkDir } from "../helpers/safePath.js";
export function registerProjectRoutes(api: Hono, adapter: StudioApiAdapter): void {
// List all projects
api.get("/projects", async (c) => {
const projects = await adapter.listProjects();
return c.json({ projects });
});
// Resolve session to project (multi-project mode)
api.get("/resolve-session/:sessionId", async (c) => {
if (!adapter.resolveSession) {
return c.json({ error: "not available" }, 404);
}
const { sessionId } = c.req.param();
const result = await adapter.resolveSession(sessionId);
if (!result) return c.json({ error: "Session not found" }, 404);
return c.json(result);
});
// Project file tree
api.get("/projects/:id", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const files = walkDir(project.dir);
return c.json({ id: project.id, files });
});
}
@@ -0,0 +1,200 @@
import type { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import { existsSync, readFileSync, mkdirSync, unlinkSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import type { StudioApiAdapter, RenderJobState } from "../types.js";
export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void {
// Scoped job store — not shared across createStudioApi() calls
const renderJobs = new Map<string, RenderJobState & { createdAt: number }>();
// TTL cleanup for completed jobs (5 minutes)
const TTL_MS = 300_000;
const CLEANUP_INTERVAL_MS = 60_000;
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
if (
typeof process !== "undefined" &&
process.env.NODE_ENV !== "production" &&
!process.argv.includes("build")
) {
cleanupTimer = setInterval(() => {
const now = Date.now();
for (const [key, job] of renderJobs) {
if (
(job.status === "complete" || job.status === "failed") &&
now - job.createdAt > TTL_MS
) {
renderJobs.delete(key);
}
}
// Self-cleanup when no jobs remain
if (renderJobs.size === 0 && cleanupTimer) {
clearInterval(cleanupTimer);
cleanupTimer = null;
}
}, CLEANUP_INTERVAL_MS);
// Prevent the timer from keeping the process alive
if (cleanupTimer && typeof cleanupTimer === "object" && "unref" in cleanupTimer) {
cleanupTimer.unref();
}
}
// Start a render
api.post("/projects/:id/render", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const body = (await c.req.json().catch(() => ({}))) as {
fps?: number;
quality?: string;
format?: string;
};
const format = body.format === "webm" ? "webm" : "mp4";
const fps: 24 | 30 | 60 = body.fps === 24 || body.fps === 60 ? body.fps : 30;
const quality = ["draft", "standard", "high"].includes(body.quality ?? "")
? (body.quality as string)
: "standard";
const now = new Date();
const datePart = now.toISOString().slice(0, 10);
const timePart = now.toTimeString().slice(0, 8).replace(/:/g, "-");
const jobId = `${project.id}_${datePart}_${timePart}`;
const rendersDir = adapter.rendersDir(project);
if (!existsSync(rendersDir)) mkdirSync(rendersDir, { recursive: true });
const ext = format === "webm" ? ".webm" : ".mp4";
const outputPath = join(rendersDir, `${jobId}${ext}`);
const jobState = adapter.startRender({
project,
outputPath,
format: format as "mp4" | "webm",
fps,
quality,
jobId,
});
renderJobs.set(jobId, { ...jobState, createdAt: Date.now() });
// Restart cleanup timer if needed
if (!cleanupTimer && typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
cleanupTimer = setInterval(() => {
const now = Date.now();
for (const [key, job] of renderJobs) {
if (
(job.status === "complete" || job.status === "failed") &&
now - job.createdAt > TTL_MS
) {
renderJobs.delete(key);
}
}
if (renderJobs.size === 0 && cleanupTimer) {
clearInterval(cleanupTimer);
cleanupTimer = null;
}
}, CLEANUP_INTERVAL_MS);
if (cleanupTimer && typeof cleanupTimer === "object" && "unref" in cleanupTimer) {
cleanupTimer.unref();
}
}
return c.json({ jobId, status: "rendering" });
});
// SSE progress stream
api.get("/render/:jobId/progress", (c) => {
const { jobId } = c.req.param();
const job = renderJobs.get(jobId);
if (!job) return c.json({ error: "not found" }, 404);
return streamSSE(c, async (stream) => {
while (true) {
const current = renderJobs.get(jobId);
if (!current) break;
await stream.writeSSE({
event: "progress",
data: JSON.stringify({
progress: current.progress,
status: current.status,
stage: current.stage,
error: current.error,
}),
});
if (current.status === "complete" || current.status === "failed") break;
await stream.sleep(500);
}
});
});
// Download render
api.get("/render/:jobId/download", (c) => {
const { jobId } = c.req.param();
const job = renderJobs.get(jobId);
if (!job?.outputPath || !existsSync(job.outputPath)) {
return c.json({ error: "not found" }, 404);
}
const isWebm = job.outputPath.endsWith(".webm");
const contentType = isWebm ? "video/webm" : "video/mp4";
const filename = job.outputPath.split("/").pop() ?? `render.mp4`;
const content = readFileSync(job.outputPath);
return new Response(content, {
headers: {
"Content-Type": contentType,
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
});
// Delete render
api.delete("/render/:jobId", (c) => {
const { jobId } = c.req.param();
for (const [, state] of renderJobs) {
if (state.id === jobId && state.outputPath) {
const dir = state.outputPath.replace(/\/[^/]+$/, "");
for (const ext of [".mp4", ".webm", ".meta.json"]) {
const fp = join(dir, `${jobId}${ext}`);
if (existsSync(fp)) unlinkSync(fp);
}
break;
}
}
renderJobs.delete(jobId);
return c.json({ deleted: true });
});
// List renders
api.get("/projects/:id/renders", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
const rendersDir = adapter.rendersDir(project);
if (!existsSync(rendersDir)) return c.json({ renders: [] });
const files = readdirSync(rendersDir)
.filter((f) => f.endsWith(".mp4") || f.endsWith(".webm"))
.map((f) => {
const fp = join(rendersDir, f);
const stat = statSync(fp);
const rid = f.replace(/\.(mp4|webm)$/, "");
const metaPath = join(rendersDir, `${rid}.meta.json`);
let status: "complete" | "failed" = "complete";
let durationMs: number | undefined;
if (existsSync(metaPath)) {
try {
const meta = JSON.parse(readFileSync(metaPath, "utf-8"));
if (meta.status === "failed") status = "failed";
if (meta.durationMs) durationMs = meta.durationMs;
} catch {
/* ignore */
}
}
return {
id: rid,
filename: f,
size: stat.size,
createdAt: stat.mtimeMs,
status,
durationMs,
};
})
.sort((a, b) => b.createdAt - a.createdAt);
return c.json({ renders: files });
});
}
@@ -0,0 +1,75 @@
import type { Hono } from "hono";
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import type { StudioApiAdapter } from "../types.js";
export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): void {
api.get("/projects/:id/thumbnail/*", async (c) => {
if (!adapter.generateThumbnail) {
return c.json({ error: "Thumbnails not available" }, 501);
}
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
let compPath = decodeURIComponent(
c.req.path.replace(`/projects/${project.id}/thumbnail/`, "").split("?")[0] ?? "",
);
if (compPath && !compPath.includes(".")) compPath += ".html";
const url = new URL(c.req.url, `http://${c.req.header("host") || "localhost"}`);
const seekTime = parseFloat(url.searchParams.get("t") || "0.5") || 0.5;
const vpWidth = parseInt(url.searchParams.get("w") || "0") || 0;
const vpHeight = parseInt(url.searchParams.get("h") || "0") || 0;
// Determine composition dimensions from HTML
let compW = vpWidth || 1920;
let compH = vpHeight || 1080;
if (!vpWidth) {
const htmlFile = join(project.dir, compPath);
if (existsSync(htmlFile)) {
const html = readFileSync(htmlFile, "utf-8");
const wMatch = html.match(/data-width=["'](\d+)["']/);
const hMatch = html.match(/data-height=["'](\d+)["']/);
if (wMatch?.[1]) compW = parseInt(wMatch[1]);
if (hMatch?.[1]) compH = parseInt(hMatch[1]);
}
}
const previewUrl =
compPath === "index.html"
? `http://${c.req.header("host")}/api/projects/${project.id}/preview`
: `http://${c.req.header("host")}/api/projects/${project.id}/preview/comp/${compPath}`;
// Cache
const cacheDir = join(project.dir, ".thumbnails");
const cacheKey = `${compPath.replace(/\//g, "_")}_${seekTime.toFixed(2)}.jpg`;
const cachePath = join(cacheDir, cacheKey);
if (existsSync(cachePath)) {
return new Response(new Uint8Array(readFileSync(cachePath)), {
headers: { "Content-Type": "image/jpeg", "Cache-Control": "public, max-age=60" },
});
}
try {
const buffer = await adapter.generateThumbnail({
project,
compPath,
seekTime,
width: compW,
height: compH,
previewUrl,
});
if (!buffer) {
return c.json({ error: "Thumbnail generation returned null" }, 500);
}
if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
writeFileSync(cachePath, buffer);
return new Response(new Uint8Array(buffer), {
headers: { "Content-Type": "image/jpeg", "Cache-Control": "public, max-age=60" },
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return c.json({ error: `Thumbnail generation failed: ${msg}` }, 500);
}
});
}
+78
View File
@@ -0,0 +1,78 @@
/** Resolved info about a single project. */
export interface ResolvedProject {
id: string;
dir: string;
title?: string;
sessionId?: string;
}
/** Observable render job state, polled by the SSE progress handler. */
export interface RenderJobState {
id: string;
status: "rendering" | "complete" | "failed";
progress: number;
stage?: string;
outputPath: string;
error?: string;
}
/** Lint result from the core linter. */
export interface LintResult {
findings: Array<{
severity: string;
message: string;
file?: string;
fixHint?: string;
}>;
}
/**
* Adapter interface injected by each consumer to handle host-specific behavior.
* The shared API module calls these methods; each host (vite dev, CLI embedded)
* provides its own implementation.
*/
export interface StudioApiAdapter {
/** List all available projects. */
listProjects(): Promise<ResolvedProject[]> | ResolvedProject[];
/** Resolve a project ID (or session ID) to its directory. Returns null if not found. */
resolveProject(id: string): Promise<ResolvedProject | null> | ResolvedProject | null;
/** Bundle a project directory into a single HTML string. Returns null if unavailable. */
bundle(projectDir: string): Promise<string | null>;
/** Lint a single HTML string. */
lint(html: string, opts?: { filePath?: string }): Promise<LintResult> | LintResult;
/** URL to the hyperframe runtime JS (injected into preview HTML). */
runtimeUrl: string;
/** Directory where render output files are stored. */
rendersDir(project: ResolvedProject): string;
/**
* Start a render job. The adapter owns the async execution and must
* update the returned RenderJobState object reactively.
*/
startRender(opts: {
project: ResolvedProject;
outputPath: string;
format: "mp4" | "webm";
fps: number;
quality: string;
jobId: string;
}): RenderJobState;
/** Optional: generate a JPEG thumbnail via Puppeteer or similar. */
generateThumbnail?: (opts: {
project: ResolvedProject;
compPath: string;
seekTime: number;
width: number;
height: number;
previewUrl: string;
}) => Promise<Buffer | null>;
/** Optional: resolve session ID to project (multi-project mode). */
resolveSession?: (sessionId: string) => Promise<{ projectId: string; title: string } | null>;
}