feat(cli): implement embedded dev server for hyperframes dev

When installed via npx, `hyperframes dev` now starts a standalone
Hono HTTP server that serves the pre-built studio SPA and implements
the project API (file listing, read/write, preview bundling,
sub-composition rendering, runtime serving, SSE file watching).

Three modes are auto-detected:
1. Monorepo dev (running from .ts source) → spawn Vite (existing)
2. Local @hyperframes/studio installed → spawn Vite via package (new)
3. Default → embedded Hono server (new, zero extra deps needed)

Also patches the studio SPA to use EventSource SSE fallback when
Vite HMR is unavailable (production/embedded builds).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
James
2026-03-26 23:34:00 +00:00
co-authored by Claude Opus 4.6
parent e554710646
commit 5ace89d634
5 changed files with 541 additions and 14 deletions
+131 -9
View File
@@ -3,6 +3,7 @@ import { spawn } from "node:child_process";
import { existsSync, lstatSync, symlinkSync, unlinkSync, readlinkSync, mkdirSync } from "node:fs"; import { existsSync, lstatSync, symlinkSync, unlinkSync, readlinkSync, mkdirSync } from "node:fs";
import { resolve, dirname, basename, join } from "node:path"; import { resolve, dirname, basename, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
import * as clack from "@clack/prompts"; import * as clack from "@clack/prompts";
import { c } from "../ui/colors.js"; import { c } from "../ui/colors.js";
import { isDevMode } from "../utils/env.js"; import { isDevMode } from "../utils/env.js";
@@ -43,6 +44,12 @@ export default defineCommand({
if (isDevMode()) { if (isDevMode()) {
return runDevMode(dir); return runDevMode(dir);
} }
// If @hyperframes/studio is installed locally, use Vite for full HMR
if (hasLocalStudio(dir)) {
return runLocalStudioMode(dir);
}
const port = await findAvailablePort(3002); const port = await findAvailablePort(3002);
return runEmbeddedMode(dir, port); return runEmbeddedMode(dir, port);
}, },
@@ -158,14 +165,129 @@ async function runDevMode(dir: string): Promise<void> {
} }
/** /**
* Embedded mode — not yet available. * Check if @hyperframes/studio is installed locally in the project's node_modules.
* TODO: Migrate to use @hyperframes/studio's built-in Vite server for published CLI.
*/ */
async function runEmbeddedMode(_dir: string, _port: number): Promise<void> { function hasLocalStudio(dir: string): boolean {
console.error( try {
c.error( const req = createRequire(join(dir, "package.json"));
"Embedded mode not yet available. Run from the monorepo root with: hyperframes dev <dir>", req.resolve("@hyperframes/studio/package.json");
), return true;
); } catch {
process.exit(1); return false;
}
}
/**
* Local studio mode: spawn Vite using a locally installed @hyperframes/studio.
* Provides full Vite HMR and the complete studio experience.
*/
async function runLocalStudioMode(dir: string): Promise<void> {
const req = createRequire(join(dir, "package.json"));
const studioPkgPath = dirname(req.resolve("@hyperframes/studio/package.json"));
const projectName = basename(dir);
// Symlink project into studio's data directory
const projectsDir = join(studioPkgPath, "data", "projects");
const symlinkPath = join(projectsDir, projectName);
mkdirSync(projectsDir, { recursive: true });
let createdSymlink = false;
if (dir !== symlinkPath) {
if (existsSync(symlinkPath) && lstatSync(symlinkPath).isSymbolicLink()) {
if (resolve(readlinkSync(symlinkPath)) !== resolve(dir)) {
unlinkSync(symlinkPath);
}
}
if (!existsSync(symlinkPath)) {
symlinkSync(dir, symlinkPath, "dir");
createdSymlink = true;
}
}
clack.intro(c.bold("hyperframes dev") + c.dim(" (local studio)"));
const s = clack.spinner();
s.start("Starting studio...");
const child = spawn("npx", ["vite"], {
cwd: studioPkgPath,
stdio: ["ignore", "pipe", "pipe"],
});
let detected = false;
function handleOutput(data: Buffer): void {
const text = data.toString();
const localMatch = text.match(/Local:\s+(http:\/\/localhost:\d+)/);
if (localMatch && !detected) {
detected = true;
const url = localMatch[1] ?? "";
s.stop(c.success("Studio running"));
console.log();
console.log(` ${c.dim("Project")} ${c.accent(projectName)}`);
console.log(` ${c.dim("Studio")} ${c.accent(url)}`);
console.log();
console.log(` ${c.dim("Press Ctrl+C to stop")}`);
console.log();
import("open").then((mod) => mod.default(`${url}#project/${projectName}`)).catch(() => {});
}
}
child.stdout?.on("data", handleOutput);
child.stderr?.on("data", handleOutput);
child.on("error", (err) => {
s.stop(c.error("Failed to start studio"));
console.error(c.dim(err.message));
});
return new Promise<void>((resolvePromise) => {
const noop = (): void => {};
process.on("SIGINT", noop);
child.on("close", () => {
process.removeListener("SIGINT", noop);
if (createdSymlink && existsSync(symlinkPath)) {
try {
unlinkSync(symlinkPath);
} catch {
/* ignore */
}
}
resolvePromise();
});
});
}
/**
* Embedded mode: serve the pre-built studio SPA with a standalone Hono server.
* Works without any additional dependencies — the studio is bundled in dist/.
*/
async function runEmbeddedMode(dir: string, port: number): Promise<void> {
const { createStudioServer } = await import("../server/studioServer.js");
const { serve } = await import("@hono/node-server");
const projectName = basename(dir);
const { app, watcher } = createStudioServer({ projectDir: dir });
clack.intro(c.bold("hyperframes dev"));
const s = clack.spinner();
s.start("Starting studio...");
const server = serve({ fetch: app.fetch, port }, () => {
const url = `http://localhost:${port}`;
s.stop(c.success("Studio running"));
console.log();
console.log(` ${c.dim("Project")} ${c.accent(projectName)}`);
console.log(` ${c.dim("Studio")} ${c.accent(url)}`);
console.log();
console.log(` ${c.dim("Press Ctrl+C to stop")}`);
console.log();
import("open").then((mod) => mod.default(`${url}#project/${projectName}`)).catch(() => {});
});
return new Promise<void>((resolvePromise) => {
process.on("SIGINT", () => {
console.log();
watcher.close();
server.close(() => resolvePromise());
});
});
} }
+49
View File
@@ -0,0 +1,49 @@
import { watch, type FSWatcher } from "node:fs";
export type FileChangeListener = (relativePath: string) => void;
export interface ProjectWatcher {
addListener(fn: FileChangeListener): void;
removeListener(fn: FileChangeListener): void;
close(): void;
}
const WATCHED_EXTENSIONS = new Set([".html", ".css", ".js", ".json"]);
const DEBOUNCE_MS = 300;
export function createProjectWatcher(projectDir: string): ProjectWatcher {
const listeners = new Set<FileChangeListener>();
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let watcher: FSWatcher | null = null;
try {
watcher = watch(projectDir, { recursive: true }, (_event, filename) => {
if (!filename) return;
const ext = "." + filename.split(".").pop()?.toLowerCase();
if (!WATCHED_EXTENSIONS.has(ext)) return;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
for (const fn of listeners) {
fn(filename);
}
}, DEBOUNCE_MS);
});
} catch {
// fs.watch may fail on some platforms — degrade gracefully (no auto-refresh)
}
return {
addListener(fn) {
listeners.add(fn);
},
removeListener(fn) {
listeners.delete(fn);
},
close() {
if (debounceTimer) clearTimeout(debounceTimer);
watcher?.close();
listeners.clear();
},
};
}
+351
View File
@@ -0,0 +1,351 @@
/**
* 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.
*/
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync } from "node:fs";
import { resolve, join, sep, basename, dirname, extname } from "node:path";
import { createProjectWatcher, type ProjectWatcher } from "./fileWatcher.js";
// ── 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
}
function resolveRuntimePath(): string {
const builtPath = resolve(__dirname, "hyperframe-runtime.js");
if (existsSync(builtPath)) return builtPath;
const devPath = resolve(
__dirname,
"..",
"..",
"..",
"core",
"dist",
"hyperframe.runtime.iife.js",
);
if (existsSync(devPath)) return devPath;
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;
// 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;
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 {
projectDir: string;
}
export interface StudioServer {
app: Hono;
watcher: ProjectWatcher;
}
export function createStudioServer(options: StudioServerOptions): StudioServer {
const { projectDir } = options;
const projectId = basename(projectDir);
const studioDir = resolveDistDir();
const runtimePath = resolveRuntimePath();
const watcher = createProjectWatcher(projectDir);
const app = new Hono();
// ── API: runtime.js ───────────────────────────────────────────────────
app.get("/api/runtime.js", (c) => {
if (!existsSync(runtimePath)) return c.text("runtime not built", 404);
return c.body(readFileSync(runtimePath, "utf-8"), 200, {
"Content-Type": "text/javascript",
"Cache-Control": "no-store",
});
});
// ── 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: 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" },
});
});
// ── 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));
app.post("/api/projects/:id/render", (c) =>
c.json({ error: "Use 'hyperframes render' CLI command instead" }, 501),
);
// ── 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);
});
app.get("/icons/*", (c) => {
const filePath = resolve(studioDir, c.req.path.slice(1));
const resp = serveStaticFile(filePath);
return resp ?? c.text("not found", 404);
});
// ── SPA fallback — serve index.html for all unmatched routes ──────────
app.get("*", (c) => {
const indexPath = resolve(studioDir, "index.html");
if (!existsSync(indexPath)) {
return c.text("Studio not found. Rebuild with: pnpm run build", 500);
}
return c.html(readFileSync(indexPath, "utf-8"));
});
return { app, watcher };
}
-1
View File
@@ -35,7 +35,6 @@ const __dirname = __hf_dirname(__filename);`,
noExternal: [ noExternal: [
"@hyperframes/core", "@hyperframes/core",
"@hyperframes/producer", "@hyperframes/producer",
"@hyperframes/studio-backend",
"@hyperframes/engine", "@hyperframes/engine",
"@clack/prompts", "@clack/prompts",
"@clack/core", "@clack/core",
+10 -4
View File
@@ -213,15 +213,21 @@ export function StudioApp() {
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const projectIdRef = useRef(projectId); const projectIdRef = useRef(projectId);
// Listen for external file changes (user editing HTML outside the editor) // Listen for external file changes (user editing HTML outside the editor).
// In dev: use Vite HMR. In embedded/production: use SSE from /api/events.
useEffect(() => { useEffect(() => {
if (!import.meta.hot) return;
const handler = () => { const handler = () => {
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 400); refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 400);
}; };
import.meta.hot.on("hf:file-change", handler); if (import.meta.hot) {
return () => import.meta.hot?.off?.("hf:file-change", handler); import.meta.hot.on("hf:file-change", handler);
return () => import.meta.hot?.off?.("hf:file-change", handler);
}
// SSE fallback for embedded studio server
const es = new EventSource("/api/events");
es.addEventListener("file-change", handler);
return () => es.close();
}, []); }, []);
projectIdRef.current = projectId; projectIdRef.current = projectId;