mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
`--background` was rejected outside the embedded server. It now re-execs the CLI in foreground, which makes it mode-agnostic by construction: whichever server the child resolves to serves the config endpoint the readiness probe looks for. `--foreground` is its counterpart, for a non-interactive shell that wants to stay attached, and a bare launch keeps the same promise — attached in an interactive terminal, managed in an agent session. That generalization exposed an existing hole. Local-studio mode runs Vite with the studio package as its cwd and needs that package's own Vite config, which the published tarball does not carry, but resolving the package was treated as proof the mode was usable. An npm-installed studio therefore took a path that can never come up — previously a clear error, now a ten-second silent timeout. The predicate becomes "can this studio actually be served", so a published install falls back to embedded mode, which works. Over the 1k line budget at ~1.3k. The overage is one command file and its tests carrying one invariant, and the seam that would split it further is inside a single request-handling function — a split there would produce two PRs neither of which starts a preview on its own.
261 lines
9.6 KiB
TypeScript
261 lines
9.6 KiB
TypeScript
import { defineConfig, type Plugin } from "vite";
|
|
import react from "@vitejs/plugin-react";
|
|
import { readFileSync, readdirSync, existsSync, lstatSync, realpathSync } from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
import { readNodeRequestBody } from "./vite.request-body.js";
|
|
import { watch } from "chokidar";
|
|
import { createViteAdapter } from "./vite.adapter";
|
|
import { previewConfigPayload } from "./vite.preview-config";
|
|
|
|
async function loadRuntimeSourceForDev(
|
|
server: import("vite").ViteDevServer,
|
|
): Promise<string | null> {
|
|
try {
|
|
const mod = await server.ssrLoadModule(
|
|
resolve(__dirname, "../core/src/inline-scripts/hyperframe.ts"),
|
|
);
|
|
if (typeof mod.loadHyperframeRuntimeSource === "function") {
|
|
return mod.loadHyperframeRuntimeSource();
|
|
}
|
|
} catch (err) {
|
|
console.warn("[Studio] Failed to load runtime source from core:", err);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const studioPkg = JSON.parse(readFileSync(resolve(__dirname, "package.json"), "utf-8"));
|
|
|
|
// ── Bridge Hono fetch → Node http response ───────────────────────────────────
|
|
|
|
async function bridgeHonoResponse(
|
|
honoResponse: Response,
|
|
res: import("node:http").ServerResponse,
|
|
): Promise<void> {
|
|
const headers: Record<string, string> = {};
|
|
honoResponse.headers.forEach((v, k) => {
|
|
headers[k] = v;
|
|
});
|
|
res.writeHead(honoResponse.status, headers);
|
|
|
|
if (!honoResponse.body) {
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
const reader = honoResponse.body.getReader();
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
res.write(value);
|
|
}
|
|
} catch {
|
|
/* client disconnected */
|
|
}
|
|
res.end();
|
|
}
|
|
|
|
// ── Vite plugin ──────────────────────────────────────────────────────────────
|
|
|
|
function devProjectApi(): Plugin {
|
|
const dataDir = resolve(__dirname, "data/projects");
|
|
const runtimePath = resolve(__dirname, "../core/dist/hyperframe.runtime.iife.js");
|
|
|
|
return {
|
|
name: "studio-dev-api",
|
|
configureServer(server): void {
|
|
let _api: { fetch: (req: Request) => Promise<Response> } | null = null;
|
|
let _studioServerModule: {
|
|
createStudioApi: (adapter: ReturnType<typeof createViteAdapter>) => {
|
|
fetch: (req: Request) => Promise<Response>;
|
|
};
|
|
consumeFileWriteReceipt?: (
|
|
path: string,
|
|
expectedVersion: string,
|
|
) => { path: string; version: string; writeToken: string } | null;
|
|
fileContentVersion?: (content: string) => string;
|
|
} | null = null;
|
|
const getApi = async () => {
|
|
if (!_api) {
|
|
const mod = await server.ssrLoadModule("@hyperframes/studio-server");
|
|
_studioServerModule = mod as typeof _studioServerModule;
|
|
const adapter = createViteAdapter(dataDir, server);
|
|
_api = mod.createStudioApi(adapter);
|
|
}
|
|
return _api;
|
|
};
|
|
|
|
server.middlewares.use((req, res, next) => {
|
|
if (req.url !== "/__hyperframes_config") return next();
|
|
const payload = previewConfigPayload(process.env, process.pid, studioPkg.version);
|
|
if (!payload) return next();
|
|
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
res.end(JSON.stringify(payload));
|
|
});
|
|
|
|
// Runtime endpoint — prefer source build over dist artifact
|
|
server.middlewares.use((req, res, next) => {
|
|
if (req.url !== "/api/runtime.js") return next();
|
|
const serve = async () => {
|
|
let runtimeSource = await loadRuntimeSourceForDev(server);
|
|
if (!runtimeSource && existsSync(runtimePath)) {
|
|
runtimeSource = readFileSync(runtimePath, "utf-8");
|
|
}
|
|
if (!runtimeSource) {
|
|
res.writeHead(404);
|
|
res.end("runtime not available — build packages/core or load runtime source");
|
|
return;
|
|
}
|
|
res.writeHead(200, {
|
|
"Content-Type": "text/javascript",
|
|
"Cache-Control": "no-store",
|
|
});
|
|
res.end(runtimeSource);
|
|
};
|
|
void serve().catch((err) => {
|
|
console.error("[Studio runtime] Failed to serve runtime", err);
|
|
if (!res.headersSent) {
|
|
res.writeHead(500);
|
|
res.end("failed to serve runtime");
|
|
}
|
|
});
|
|
});
|
|
|
|
// API middleware
|
|
server.middlewares.use(async (req, res, next) => {
|
|
if (!req.url?.startsWith("/api/")) return next();
|
|
try {
|
|
const api = await getApi();
|
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
url.pathname = url.pathname.slice(4);
|
|
let body: Buffer | undefined;
|
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
const bytes = await readNodeRequestBody(req);
|
|
body = bytes.byteLength > 0 ? bytes : undefined;
|
|
}
|
|
const headers: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(req.headers)) {
|
|
if (value != null) headers[key] = Array.isArray(value) ? value.join(", ") : value;
|
|
}
|
|
const fetchReq = new Request(url.toString(), {
|
|
method: req.method,
|
|
headers,
|
|
body,
|
|
});
|
|
const response = await api.fetch(fetchReq);
|
|
await bridgeHonoResponse(response, res);
|
|
} catch (err) {
|
|
console.error("[Studio API] Error:", err);
|
|
if (!res.headersSent) {
|
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ error: "Internal server error" }));
|
|
}
|
|
}
|
|
});
|
|
|
|
// Watch project directories on a watcher of our own. Vite's is told to
|
|
// ignore them (see `server.watch.ignored`), because it answers an html
|
|
// change with a full page reload; this one only announces the change and
|
|
// lets Studio decide what to do with it.
|
|
const realProjectPaths: string[] = [];
|
|
try {
|
|
for (const entry of readdirSync(dataDir, { withFileTypes: true })) {
|
|
const full = join(dataDir, entry.name);
|
|
try {
|
|
realProjectPaths.push(lstatSync(full).isSymbolicLink() ? realpathSync(full) : full);
|
|
} catch {
|
|
/* skip broken symlinks */
|
|
}
|
|
}
|
|
} catch {
|
|
/* dataDir doesn't exist yet */
|
|
}
|
|
|
|
const projectWatcher = watch(realProjectPaths, {
|
|
ignoreInitial: true,
|
|
// A project write is a whole-file replace; wait for it to settle so a
|
|
// half-written composition is never announced.
|
|
awaitWriteFinish: { stabilityThreshold: 40, pollInterval: 10 },
|
|
});
|
|
projectWatcher.on("change", (filePath: string) => {
|
|
if (
|
|
!filePath.endsWith(".html") &&
|
|
!filePath.endsWith(".css") &&
|
|
!filePath.endsWith(".js") &&
|
|
!filePath.endsWith(".json")
|
|
)
|
|
return;
|
|
console.log(`[Studio] File changed: ${filePath}`);
|
|
// The receipt is matched on the file's current bytes, not just its path,
|
|
// so a write is only recognised as ours when the version agrees. Calling
|
|
// this without the version could never match, which left every Studio
|
|
// write looking external and reloaded the preview on each edit.
|
|
let version: string | null = null;
|
|
try {
|
|
version =
|
|
_studioServerModule?.fileContentVersion?.(readFileSync(filePath, "utf-8")) ?? null;
|
|
} catch {
|
|
// A deletion has no current bytes to match a write receipt against.
|
|
}
|
|
const receipt = version
|
|
? (_studioServerModule?.consumeFileWriteReceipt?.(filePath, version) ?? null)
|
|
: null;
|
|
server.ws.send({
|
|
type: "custom",
|
|
event: "hf:file-change",
|
|
data: receipt ?? { path: filePath },
|
|
});
|
|
});
|
|
server.httpServer?.on("close", () => void projectWatcher.close());
|
|
},
|
|
};
|
|
}
|
|
|
|
export default defineConfig({
|
|
plugins: [react(), devProjectApi()],
|
|
define: {
|
|
__STUDIO_VERSION__: JSON.stringify(studioPkg.version),
|
|
},
|
|
resolve: {
|
|
alias: {
|
|
"@hyperframes/player": resolve(__dirname, "../player/src/hyperframes-player.ts"),
|
|
"@hyperframes/studio-server/source-mutation": resolve(
|
|
__dirname,
|
|
"../studio-server/src/helpers/sourceMutation.ts",
|
|
),
|
|
},
|
|
},
|
|
build: {
|
|
outDir: "dist",
|
|
emptyOutDir: true,
|
|
},
|
|
optimizeDeps: {
|
|
include: ["bpm-detective"],
|
|
},
|
|
server: {
|
|
port: 5190,
|
|
watch: {
|
|
// A composition lives under this package's root, so Vite's HMR sees a
|
|
// write to one as an html page dependency changing and full-reloads the
|
|
// browser. That reload is the flash after every edit in the canvas, and
|
|
// it is not Studio's to make: the app already decides whether a write of
|
|
// its own needs the preview refreshed, and the plugin below announces
|
|
// project writes as `hf:file-change` off its own watcher.
|
|
ignored: ["**/data/projects/**"],
|
|
},
|
|
},
|
|
ssr: {
|
|
// recast / @babel/parser are CommonJS and call `require("fs")`. They are
|
|
// reachable only server-side via the Node-only `@hyperframes/parsers/gsap-parser`
|
|
// subpath (studio-api GSAP mutations + the linter), which the dev server loads
|
|
// through Vite SSR. Externalizing them makes SSR load the native Node modules
|
|
// instead of esbuild-transforming the `require` into a shim that throws
|
|
// "Dynamic require of fs is not supported". Browser bundles never reach them.
|
|
external: ["recast", "@babel/parser", "ast-types"],
|
|
},
|
|
test: {
|
|
exclude: ["data/**", "node_modules/**"],
|
|
setupFiles: ["src/test-setup.ts"],
|
|
},
|
|
});
|