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 { 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());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user