Skip to content
74 changes: 70 additions & 4 deletions packages/cli/src/server/runtimeSource.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,77 @@
export async function loadRuntimeSourceFallback(): Promise<string | null> {
import { existsSync, readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";

const ARTIFACT_NAMES = ["hyperframe-runtime.js", "hyperframe.runtime.iife.js"];

/**
* Resolve the runtime JS source for the studio preview server.
*
* Two contexts exist:
*
* Dev (monorepo workspace) — `entry.ts` exists next to `@hyperframes/core`
* source. We build from source via esbuild so edits to the runtime are
* reflected without a manual `bun run build`.
*
* Installed (npm global / npx) — only `dist/` ships. We read the pre-built
* IIFE artifact that `build:runtime` copies alongside `cli.js`.
*
* The priority chain:
* 1. esbuild from source (dev only — gated on entry.ts existence)
* 2. pre-built artifact (alongside cli.js in dist/)
* 3. core/dist artifact (dev fallback if build:runtime already ran)
* 4. node_modules walk (nested install edge cases)
*/
export async function loadRuntimeSource(): Promise<string | null> {
return (await buildFromSource()) ?? readPrebuiltArtifact();
}

// ── Strategy 1: live build from source (dev only) ──────────────────────────

const ENTRY_TS = resolve(__dirname, "..", "..", "..", "core", "src", "runtime", "entry.ts");

async function buildFromSource(): Promise<string | null> {
if (!existsSync(ENTRY_TS)) return null;
try {
const mod = await import("@hyperframes/core");
if (typeof mod.loadHyperframeRuntimeSource === "function") {
return mod.loadHyperframeRuntimeSource();
const source = mod.loadHyperframeRuntimeSource();
if (source) return source;
}
} catch {
// esbuild failed — fall through to artifact
}
return null;
}

// ── Strategy 2-4: pre-built IIFE artifact ──────────────────────────────────

function readPrebuiltArtifact(): string | null {
return readFromDir(__dirname) ?? readFromCoreDistDir() ?? readFromNodeModules();
}

function readFromDir(dir: string): string | null {
for (const name of ARTIFACT_NAMES) {
const path = resolve(dir, name);
if (existsSync(path)) return readFileSync(path, "utf-8");
}
return null;
}

function readFromCoreDistDir(): string | null {
return readFromDir(resolve(__dirname, "..", "..", "..", "core", "dist"));
}

function readFromNodeModules(): string | null {
const subPaths = ["node_modules/hyperframes/dist", "node_modules/@hyperframes/core/dist"];
let dir = __dirname;
for (;;) {
for (const sub of subPaths) {
const result = readFromDir(resolve(dir, sub));
if (result) return result;
}
} catch (err) {
console.warn("[studio] Failed to load runtime source fallback:", err);
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
}
6 changes: 3 additions & 3 deletions packages/cli/src/server/studioServer.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { describe, expect, it } from "vitest";
import { loadHyperframeRuntimeSource } from "@hyperframes/core";
import { loadRuntimeSourceFallback } from "./runtimeSource.js";
import { loadRuntimeSource } from "./runtimeSource.js";

describe("loadRuntimeSourceFallback", () => {
describe("loadRuntimeSource", () => {
it("loads runtime source from the published core entrypoint", async () => {
await expect(loadRuntimeSourceFallback()).resolves.toBe(loadHyperframeRuntimeSource());
await expect(loadRuntimeSource()).resolves.toBe(loadHyperframeRuntimeSource());
});
});
10 changes: 4 additions & 6 deletions packages/cli/src/server/studioServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { streamSSE } from "hono/streaming";
import { existsSync, readFileSync, writeFileSync, statSync } from "node:fs";
import { resolve, join, basename } from "node:path";
import { createProjectWatcher, type ProjectWatcher } from "./fileWatcher.js";
import { loadRuntimeSourceFallback } from "./runtimeSource.js";
import { loadRuntimeSource } from "./runtimeSource.js";
import { VERSION as version } from "../version.js";
import {
createStudioApi,
Expand All @@ -33,6 +33,8 @@ function resolveDistDir(): string {
function resolveRuntimePath(): string {
const builtPath = resolve(__dirname, "hyperframe-runtime.js");
if (existsSync(builtPath)) return builtPath;
const iifePath = resolve(__dirname, "hyperframe.runtime.iife.js");
if (existsSync(iifePath)) return iifePath;
const devPath = resolve(
__dirname,
"..",
Expand Down Expand Up @@ -282,12 +284,8 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
// CLI-specific routes (before shared API)
app.get("/api/runtime.js", (c) => {
const serve = async () => {
// Prefer the runtime generated from the current core source over a
// potentially stale copied artifact. This keeps local studio/preview
// sessions aligned with source edits without requiring a manual
// rebuild of the CLI runtime bundle first.
const runtimeSource =
(await loadRuntimeSourceFallback()) ??
(await loadRuntimeSource()) ??
(existsSync(runtimePath) ? readFileSync(runtimePath, "utf-8") : null);
if (!runtimeSource) return c.text("runtime not available", 404);
return c.body(runtimeSource, 200, {
Expand Down
1 change: 0 additions & 1 deletion packages/engine/src/services/chunkEncoder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,6 @@ describe("buildEncoderArgs HDR color space", () => {
expect.stringContaining("HDR is not supported with codec=h264"),
);
warnSpy.mockRestore();

});

it("uses range conversion for HDR CPU encoding", () => {
Expand Down
Loading