From 9ea84e92a0a31b51f1d4d94b3b4d15315a2f6467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 23 Apr 2026 15:16:43 -0400 Subject: [PATCH 1/4] fix(core): guard buildHyperframesRuntimeScript against missing entry.ts buildHyperframesRuntimeScript() now checks existsSync(entryPath) before calling esbuild.buildSync(). When entry.ts doesn't exist (bundled or published contexts where only dist/ ships), it returns null instead of letting esbuild fail with stderr output. This prevents any future consumer that bundles @hyperframes/core from rediscovering the "esbuild can't find entry.ts" bug. Updated loadHyperframeRuntimeSource() and all call-site test scripts to handle the nullable return type. --- .../build-hyperframes-runtime-artifact.ts | 34 ++++++++++++++++++- .../test-hyperframe-runtime-behavior.ts | 4 +++ .../test-hyperframe-runtime-contract.ts | 1 + .../core/src/inline-scripts/hyperframe.ts | 2 +- .../hyperframesRuntime.engine.ts | 12 ++++++- .../scripts/test-fitTextFontSize-browser.ts | 4 +++ 6 files changed, 54 insertions(+), 3 deletions(-) diff --git a/packages/core/scripts/build-hyperframes-runtime-artifact.ts b/packages/core/scripts/build-hyperframes-runtime-artifact.ts index bf556cfc1b..3042e17706 100644 --- a/packages/core/scripts/build-hyperframes-runtime-artifact.ts +++ b/packages/core/scripts/build-hyperframes-runtime-artifact.ts @@ -15,7 +15,11 @@ const iifePath = resolve(distDir, HYPERFRAME_RUNTIME_ARTIFACTS.iife); const esmPath = resolve(distDir, HYPERFRAME_RUNTIME_ARTIFACTS.esm); const manifestPath = resolve(distDir, HYPERFRAME_RUNTIME_ARTIFACTS.manifest); -const runtimeSource = `${loadHyperframeRuntimeSource()}\n`; +const runtimeSourceRaw = loadHyperframeRuntimeSource(); +if (runtimeSourceRaw === null) { + throw new Error("Cannot build runtime artifact: entry.ts not found at expected path"); +} +const runtimeSource = `${runtimeSourceRaw}\n`; const runtimeSha256 = createHash("sha256").update(runtimeSource, "utf8").digest("hex"); const buildId = process.env.HYPERFRAME_RUNTIME_BUILD_ID?.trim() || "dev"; const runtimeEntryPath = resolve(thisDir, "../src/runtime/entry.ts"); @@ -47,6 +51,33 @@ writeFileSync(iifePath, runtimeSource, "utf8"); writeFileSync(esmPath, esmSource, "utf8"); writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); +// ── Generate src/generated/runtime-inline.ts ────────────────────────────── +// This file is compiled by tsc into dist/ and provides the production-safe +// getHyperframeRuntimeScript() that returns the IIFE as a string constant — +// no esbuild, no file I/O, no import.meta.url arithmetic. +const generatedDir = resolve(thisDir, "../src/generated"); +mkdirSync(generatedDir, { recursive: true }); +const inlineModulePath = resolve(generatedDir, "runtime-inline.ts"); +const escapedSource = JSON.stringify(runtimeSourceRaw); +writeFileSync( + inlineModulePath, + [ + "// AUTO-GENERATED by scripts/build-hyperframes-runtime-artifact.ts — do not edit", + `const RUNTIME_IIFE: string = ${escapedSource};`, + "", + "/**", + " * Returns the pre-built hyperframe runtime IIFE as a string constant.", + " * This is the production-safe path: no esbuild, no file I/O,", + " * no import.meta.url arithmetic.", + " */", + "export function getHyperframeRuntimeScript(): string {", + " return RUNTIME_IIFE;", + "}", + "", + ].join("\n"), + "utf8", +); + console.log( JSON.stringify({ event: "hyperframe_runtime_artifacts_generated", @@ -55,6 +86,7 @@ console.log( iifePath, esmPath, manifestPath, + inlineModulePath, sourceBytes: Buffer.byteLength(runtimeSource, "utf8"), esmBytes: Buffer.byteLength(esmSource, "utf8"), sha256: runtimeSha256, diff --git a/packages/core/scripts/test-hyperframe-runtime-behavior.ts b/packages/core/scripts/test-hyperframe-runtime-behavior.ts index 0ac5325f9f..fa02f0c811 100644 --- a/packages/core/scripts/test-hyperframe-runtime-behavior.ts +++ b/packages/core/scripts/test-hyperframe-runtime-behavior.ts @@ -7,11 +7,15 @@ function assert(condition: unknown, message: string): void { } const baseline = buildHyperframesRuntimeScript(); +assert(baseline !== null, "buildHyperframesRuntimeScript() returned null — entry.ts not found"); const parityEnabled = buildHyperframesRuntimeScript({ defaultParityMode: true }); +assert(parityEnabled !== null, "Parity-enabled build returned null"); const parityDisabled = buildHyperframesRuntimeScript({ defaultParityMode: false }); +assert(parityDisabled !== null, "Parity-disabled build returned null"); const withSourceUrl = buildHyperframesRuntimeScript({ sourceUrl: "hyperframe.runtime.iife.js", }); +assert(withSourceUrl !== null, "Build with sourceUrl returned null"); assert(baseline.includes("window.__player"), "Baseline runtime should include player contract"); assert(parityEnabled.length > 0, "Parity-enabled build should produce non-empty runtime source"); diff --git a/packages/core/scripts/test-hyperframe-runtime-contract.ts b/packages/core/scripts/test-hyperframe-runtime-contract.ts index e0c1cfb165..699dafb374 100644 --- a/packages/core/scripts/test-hyperframe-runtime-contract.ts +++ b/packages/core/scripts/test-hyperframe-runtime-contract.ts @@ -10,6 +10,7 @@ function assert(condition: unknown, message: string): void { } const runtimeSource = loadHyperframeRuntimeSource(); +assert(runtimeSource !== null, "loadHyperframeRuntimeSource() returned null — entry.ts not found"); const requiredSnippets = [ "window.__player", diff --git a/packages/core/src/inline-scripts/hyperframe.ts b/packages/core/src/inline-scripts/hyperframe.ts index 6987833390..9f2c2c311a 100644 --- a/packages/core/src/inline-scripts/hyperframe.ts +++ b/packages/core/src/inline-scripts/hyperframe.ts @@ -17,6 +17,6 @@ export const HYPERFRAME_RUNTIME_CONTRACT: HyperframeRuntimeContract = { messageSources: HYPERFRAME_BRIDGE_SOURCES, }; -export function loadHyperframeRuntimeSource(): string { +export function loadHyperframeRuntimeSource(): string | null { return buildHyperframesRuntimeScript(); } diff --git a/packages/core/src/inline-scripts/hyperframesRuntime.engine.ts b/packages/core/src/inline-scripts/hyperframesRuntime.engine.ts index 1d1e94cfc9..a4335f5471 100644 --- a/packages/core/src/inline-scripts/hyperframesRuntime.engine.ts +++ b/packages/core/src/inline-scripts/hyperframesRuntime.engine.ts @@ -1,4 +1,5 @@ import { buildSync } from "esbuild"; +import { existsSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -17,10 +18,19 @@ function applyDefaultParityMode(script: string, enabled: boolean): string { ); } +/** + * Build the runtime IIFE from source via esbuild. + * + * Returns `null` when `entry.ts` does not exist at the resolved path — + * this happens in bundled / published contexts where only `dist/` ships. + * Callers must fall back to the pre-built artifact or the inlined constant. + */ export function buildHyperframesRuntimeScript( options: HyperframesRuntimeBuildOptions = {}, -): string { +): string | null { const entryPath = resolve(dirname(fileURLToPath(import.meta.url)), "../runtime/entry.ts"); + if (!existsSync(entryPath)) return null; + const result = buildSync({ entryPoints: [entryPath], bundle: true, diff --git a/packages/engine/scripts/test-fitTextFontSize-browser.ts b/packages/engine/scripts/test-fitTextFontSize-browser.ts index 0211ffcc04..c149354649 100644 --- a/packages/engine/scripts/test-fitTextFontSize-browser.ts +++ b/packages/engine/scripts/test-fitTextFontSize-browser.ts @@ -33,6 +33,10 @@ async function main() { } const runtimeSource = buildHyperframesRuntimeScript({ minify: false }); + assert( + runtimeSource !== null, + "buildHyperframesRuntimeScript returned null — entry.ts not found", + ); const html = ` From 6ef00b0054515d2fec18b4c33f28ed542d217cbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 23 Apr 2026 15:18:14 -0400 Subject: [PATCH 2/4] feat(core): add getHyperframeRuntimeScript() inlined constant The build script now generates src/generated/runtime-inline.ts during build:hyperframes-runtime, containing the IIFE as a string constant. tsc then compiles it into dist/generated/runtime-inline.js. getHyperframeRuntimeScript() is the production-safe path: no esbuild, no file I/O, no import.meta.url arithmetic. It is exported from the @hyperframes/core package index. Build order changed from "tsc && build:hyperframes-runtime" to "build:hyperframes-runtime && tsc" so the generated file exists before tsc runs. --- .gitignore | 1 + packages/core/package.json | 2 +- packages/core/src/index.ts | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 5f127cdb15..b23d83d47b 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,7 @@ tmp/ .tmp/ # Generated files +packages/core/src/generated/ packages/producer/src/services/fontData.generated.ts # Test artifacts diff --git a/packages/core/package.json b/packages/core/package.json index 7ef0bed3a4..4d408afc24 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -80,7 +80,7 @@ "types": "./dist/index.d.ts" }, "scripts": { - "build": "tsc && bun run build:hyperframes-runtime", + "build": "bun run build:hyperframes-runtime && tsc", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 490cafc4e6..eef7d35631 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -139,6 +139,7 @@ export { HYPERFRAME_CONTROL_ACTIONS, type HyperframeControlAction, } from "./inline-scripts/runtimeContract"; +export { getHyperframeRuntimeScript } from "./generated/runtime-inline"; export { buildHyperframesRuntimeScript, type HyperframesRuntimeBuildOptions, From 2df143da2664d931b0f924b24162ca100fb5a499 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 23 Apr 2026 15:19:53 -0400 Subject: [PATCH 3/4] fix(cli): use inlined runtime constant as production fallback loadRuntimeSource() now has three resolution strategies: 1. esbuild from source (dev only) 2. Inlined constant via getHyperframeRuntimeScript() (production) 3. Pre-built IIFE artifact file (final fallback) The inlined constant path avoids file I/O entirely, making runtime resolution work reliably in bundled/published contexts. --- packages/cli/src/server/runtimeSource.ts | 37 ++++++++++++++---------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/server/runtimeSource.ts b/packages/cli/src/server/runtimeSource.ts index 7bc74ed172..258dbd997f 100644 --- a/packages/cli/src/server/runtimeSource.ts +++ b/packages/cli/src/server/runtimeSource.ts @@ -6,23 +6,14 @@ const ARTIFACT_NAMES = ["hyperframe-runtime.js", "hyperframe.runtime.iife.js"]; /** * Resolve the runtime JS source for the studio preview server. * - * Two contexts exist: + * Three resolution strategies, in priority order: * - * 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) + * 1. esbuild from source (dev only — gated on entry.ts existence) + * 2. Inlined constant (production — baked into @hyperframes/core at build time) + * 3. Pre-built artifact (fallback — reads IIFE file from dist/) */ export async function loadRuntimeSource(): Promise { - return (await buildFromSource()) ?? readPrebuiltArtifact(); + return (await buildFromSource()) ?? (await getInlinedRuntime()) ?? readPrebuiltArtifact(); } // ── Strategy 1: live build from source (dev only) ────────────────────────── @@ -38,12 +29,26 @@ async function buildFromSource(): Promise { if (source) return source; } } catch { - // esbuild failed — fall through to artifact + // esbuild failed — fall through to inlined / artifact + } + return null; +} + +// ── Strategy 2: inlined constant from core build ────────────────────────── + +async function getInlinedRuntime(): Promise { + try { + const mod = await import("@hyperframes/core"); + if (typeof mod.getHyperframeRuntimeScript === "function") { + return mod.getHyperframeRuntimeScript() ?? null; + } + } catch { + // Not available — fall through to artifact } return null; } -// ── Strategy 2-4: pre-built IIFE artifact ────────────────────────────────── +// ── Strategy 3: pre-built IIFE artifact ─────────────────────────────────── function readPrebuiltArtifact(): string | null { return readFromDir(__dirname) ?? readFromCoreDistDir() ?? readFromNodeModules(); From 0d16a18f127e108c94f4c12bd11dc2162acf9c8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 23 Apr 2026 21:28:04 +0200 Subject: [PATCH 4/4] ci: add global-install smoke test for CLI preview (#455) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds a CI smoke test that reproduces the exact failure from #452 — `hyperframes preview` printing `✘ [ERROR] Could not resolve "…/runtime/entry.ts"` when installed globally via npm. The job simulates what a real user does: 1. `npm pack` the CLI → install globally with `--prefix` 2. `hyperframes init test-project --example blank` 3. `hyperframes preview --port 3099` in background 4. `curl http://localhost:3099/api/runtime.js` — assert non-empty JS 5. Assert stderr has no `✘ [ERROR]` or `Failed to load runtime` Also adds `.github/workflows/**` to the change detection filter so CI jobs run when workflow files change. ## Validation: the test catches the broken state Verified locally that the grep pattern fires on the pre-#452 code and passes on the fixed code: **Broken (0.4.15-alpha.1, globally installed via `npm i -g hyperframes@alpha`):** ``` $ hyperframes preview --port 3098 2>/tmp/hf-stderr.log $ grep -E '✘ \[ERROR\]|Failed to load runtime' /tmp/hf-stderr.log ✘ [ERROR] Could not resolve "/opt/homebrew/lib/node_modules/hyperframes/runtime/entry.ts" [studio] Failed to load runtime source fallback: Error: Build failed with 1 error: … → CAUGHT: assertion fires, test fails ✓ ``` **Fixed (built from #452 fix branch):** ``` $ node packages/cli/dist/cli.js preview --port 3098 2>/tmp/hf-stderr.log $ grep -E '✘ \[ERROR\]|Failed to load runtime' /tmp/hf-stderr.log (empty) → PASS: no errors ✓ ``` If this smoke test had existed before #452, it would have caught the bug before it shipped. ## Test plan - [x] Grep pattern catches broken state — verified locally against pre-#452 global install - [x] Fixed state passes — verified locally against #452-fixed build - [x] CI job runs green — https://github.com/heygen-com/hyperframes/actions/runs/24854025990/job/72762086753 --- .github/workflows/ci.yml | 72 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 98e770466b..b41efb1e0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,7 @@ jobs: - "bun.lock" - "tsconfig*.json" - "Dockerfile*" + - ".github/workflows/**" build: name: Build @@ -138,6 +139,77 @@ jobs: - run: bun install --frozen-lockfile - run: bun run --filter @hyperframes/core test:hyperframe-runtime-ci + smoke-global-install: + name: "Smoke: global install" + needs: [changes, build] + if: needs.changes.outputs.code == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + lfs: true + - uses: oven-sh/setup-bun@v2 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: bun install --frozen-lockfile + - run: bun run build + + # Pack the CLI as a tarball (simulates what `npm publish` produces) + - name: Pack CLI tarball + run: cd packages/cli && npm pack + + # Install globally using --prefix to avoid sudo + - name: Install globally via npm + run: npm install -g --prefix /tmp/hf-smoke ./packages/cli/hyperframes-cli-*.tgz + + # Scaffold a blank project + - name: Init blank project + run: | + export PATH="/tmp/hf-smoke/bin:$PATH" + mkdir /tmp/hf-project && cd /tmp/hf-project + hyperframes init test-project --example blank + + # Start preview, probe the runtime endpoint, assert no esbuild errors + - name: Smoke-test preview server + run: | + export PATH="/tmp/hf-smoke/bin:$PATH" + cd /tmp/hf-project/test-project + + # Start the preview server in the background; capture stderr + CI=true hyperframes preview --port 3099 2>/tmp/hf-stderr.log & + SERVER_PID=$! + + # Wait for the server to be ready (up to 15 s) + for i in $(seq 1 30); do + if curl -sf http://localhost:3099/ >/dev/null 2>&1; then + break + fi + sleep 0.5 + done + + # Probe the runtime JS endpoint + BODY=$(curl -sf http://localhost:3099/api/runtime.js | head -c 200 || true) + if [ -z "$BODY" ]; then + echo "FAIL: /api/runtime.js returned empty response" + kill $SERVER_PID 2>/dev/null || true + cat /tmp/hf-stderr.log + exit 1 + fi + + kill $SERVER_PID 2>/dev/null || true + wait $SERVER_PID 2>/dev/null || true + + # Assert stderr does not contain esbuild / runtime load errors + if grep -qE '✘ \[ERROR\]|Failed to load runtime' /tmp/hf-stderr.log; then + echo "FAIL: preview emitted runtime errors:" + cat /tmp/hf-stderr.log + exit 1 + fi + + echo "PASS: global install smoke test succeeded" + semantic-pr-title: name: Semantic PR title if: github.event_name == 'pull_request'