From 5ace89d634dae4f00176199f2582af2a723dbadc Mon Sep 17 00:00:00 2001 From: James Date: Thu, 26 Mar 2026 23:34:00 +0000 Subject: [PATCH] feat(cli): implement embedded dev server for hyperframes dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/cli/src/commands/dev.ts | 140 +++++++++- packages/cli/src/server/fileWatcher.ts | 49 ++++ packages/cli/src/server/studioServer.ts | 351 ++++++++++++++++++++++++ packages/cli/tsup.config.ts | 1 - packages/studio/src/App.tsx | 14 +- 5 files changed, 541 insertions(+), 14 deletions(-) create mode 100644 packages/cli/src/server/fileWatcher.ts create mode 100644 packages/cli/src/server/studioServer.ts diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index dd7789b20..a232232cb 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -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 { } /** - * 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 { - console.error( - c.error( - "Embedded mode not yet available. Run from the monorepo root with: hyperframes dev ", - ), - ); - 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 { + 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((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 { + 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((resolvePromise) => { + process.on("SIGINT", () => { + console.log(); + watcher.close(); + server.close(() => resolvePromise()); + }); + }); } diff --git a/packages/cli/src/server/fileWatcher.ts b/packages/cli/src/server/fileWatcher.ts new file mode 100644 index 000000000..ffe0620a9 --- /dev/null +++ b/packages/cli/src/server/fileWatcher.ts @@ -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(); + let debounceTimer: ReturnType | 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(); + }, + }; +} diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts new file mode 100644 index 000000000..b47474cb7 --- /dev/null +++ b/packages/cli/src/server/studioServer.ts @@ -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 = { + ".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