mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +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 { resolve, dirname, basename, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createRequire } from "node:module";
|
||||
import * as clack from "@clack/prompts";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { isDevMode } from "../utils/env.js";
|
||||
@@ -43,6 +44,12 @@ export default defineCommand({
|
||||
if (isDevMode()) {
|
||||
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);
|
||||
return runEmbeddedMode(dir, port);
|
||||
},
|
||||
@@ -158,14 +165,129 @@ async function runDevMode(dir: string): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Embedded mode — not yet available.
|
||||
* TODO: Migrate to use @hyperframes/studio's built-in Vite server for published CLI.
|
||||
* Check if @hyperframes/studio is installed locally in the project's node_modules.
|
||||
*/
|
||||
async function runEmbeddedMode(_dir: string, _port: number): Promise<void> {
|
||||
console.error(
|
||||
c.error(
|
||||
"Embedded mode not yet available. Run from the monorepo root with: hyperframes dev <dir>",
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
function hasLocalStudio(dir: string): boolean {
|
||||
try {
|
||||
const req = createRequire(join(dir, "package.json"));
|
||||
req.resolve("@hyperframes/studio/package.json");
|
||||
return true;
|
||||
} catch {
|
||||
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());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user