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 { 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());
});
});
}