mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user